Skip to content

Commit 0df7cde

Browse files
committed
Optimized the code
1 parent 21d01f2 commit 0df7cde

16 files changed

Lines changed: 363 additions & 280 deletions

nmstats.Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-preview-noble-aot AS restore
1+
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS restore
22
ARG TARGETARCH
33
ENV RUNTIME_IDENTIFIER=linux-${TARGETARCH}
44
WORKDIR /build
@@ -22,7 +22,7 @@ RUN dotnet publish "src/BUTR.NexusModsStats/BUTR.NexusModsStats.csproj" -c Relea
2222

2323
RUN upx --best --lzma /app/publish/BUTR.NexusModsStats || echo "UPX failed, continuing"
2424

25-
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled AS final
25+
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
2626
WORKDIR /app
2727

2828
COPY --from=publish /app/publish /app
Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
4-
<TargetFramework>net9.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66

77
<ServerGarbageCollection>false</ServerGarbageCollection>
@@ -29,27 +29,17 @@
2929
</ItemGroup>
3030

3131
<ItemGroup>
32-
<!--
33-
<PackageReference Include="Community.Microsoft.Extensions.Caching.PostgreSql" Version="5.0.0" />
34-
<PackageReference Include="Dapper" Version="2.1.66" />
35-
-->
36-
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.4" />
37-
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.2" />
38-
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.4.0" />
39-
<PackageReference Include="Npgsql" Version="9.0.3" />
40-
<PackageReference Include="Npgsql.OpenTelemetry" Version="9.0.3" />
41-
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.2" />
42-
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" />
43-
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
44-
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
45-
<PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.11.0-beta.2" />
46-
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
47-
<PackageReference Include="Polly" Version="8.5.2" />
48-
<PackageReference Include="RateLimiter" Version="2.2.0" />
49-
<PackageReference Include="Semver" Version="3.0.0" />
50-
<PackageReference Include="Sep" Version="0.9.0" />
51-
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
52-
<PackageReference Include="System.Text.Json" Version="9.0.4" />
32+
<PackageReference Include="Community.Microsoft.Extensions.Caching.PostgreSql" Version="6.0.2" />
33+
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
34+
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
35+
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
36+
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
37+
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
38+
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
39+
<PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.16.0-beta.1" />
40+
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
41+
<PackageReference Include="Polly" Version="8.7.0" />
42+
<PackageReference Include="Sep" Version="0.15.0" />
5343
</ItemGroup>
5444

5545
</Project>

src/BUTR.NexusModsStats/Extensions/DownloadsExtensions.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using BUTR.NexusModsStats.Models;
22
using BUTR.NexusModsStats.Services;
3+
using BUTR.NexusModsStats.Utils;
34

45
using Microsoft.AspNetCore.Mvc;
56
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -10,14 +11,11 @@ public static class DownloadsExtensions
1011
{
1112
public static WebApplicationBuilder AddDownloadsEndpoint(this WebApplicationBuilder builder)
1213
{
13-
var assemblyName = typeof(DownloadsExtensions).Assembly.GetName();
14-
var userAgent = $"{assemblyName.Name ?? "ERROR"} v{assemblyName.Version?.ToString() ?? "ERROR"} (github.com/BUTR)";
15-
1614
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IEndpointDefinition, DownloadsEndpointDefinition>());
1715
builder.Services.AddHttpClient<INexusModsStatisticsClient, NexusModsStatisticsClient>().ConfigureHttpClient((_, client) =>
1816
{
1917
client.BaseAddress = new Uri("https://staticstats.nexusmods.com/");
20-
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
18+
client.DefaultRequestHeaders.Add("User-Agent", HttpUtils.UserAgent);
2119
}).AddCustomResilienceHandler();
2220
return builder;
2321
}
@@ -31,8 +29,8 @@ public void RegisterEndpoints(WebApplication app)
3129
[FromServices] INexusModsStatisticsClient client,
3230
CancellationToken ct) =>
3331
{
34-
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(gameId) || string.IsNullOrEmpty(modId))
35-
return ShieldsResponseBody.Error("", "Missing required query parameters!");
32+
if (!RequestValidation.IsValidId(gameId) || !RequestValidation.IsValidId(modId))
33+
return ShieldsResponseBody.Error("", "Invalid 'gameId' or 'modId'!");
3634

3735
var label = type switch
3836
{
@@ -45,7 +43,7 @@ public void RegisterEndpoints(WebApplication app)
4543
if (string.IsNullOrEmpty(label))
4644
return ShieldsResponseBody.Error("", "Unknown type!");
4745

48-
var download = await client.GetLiveDownloadCountsAsync(gameId, ct).FirstOrDefaultAsync(x => x.Id == modId, ct);
46+
var download = await client.GetLiveDownloadCountsAsync(gameId, modId, ct);
4947
if (download is null)
5048
return ShieldsResponseBody.Error(label, "mod not found!");
5149

src/BUTR.NexusModsStats/Extensions/IHttpClientBuilderExtensions.cs

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,52 @@ namespace BUTR.NexusModsStats.Extensions;
99

1010
public static class IHttpClientBuilderExtensions
1111
{
12+
/// <summary>
13+
/// The standard retry strategy plus a delay generator that honors the NexusMods rate-limit reset headers.
14+
/// </summary>
1215
public static IHttpStandardResiliencePipelineBuilder AddNexusModsResilienceHandler(this IHttpClientBuilder builder) => builder.AddStandardResilienceHandler(options =>
1316
{
14-
options.Retry = new HttpRetryStrategyOptions
17+
options.Retry = CreateRetryOptions();
18+
options.Retry.DelayGenerator = static args =>
1519
{
16-
MaxRetryAttempts = 5,
17-
BackoffType = DelayBackoffType.Exponential,
18-
UseJitter = true,
19-
Delay = TimeSpan.FromSeconds(1),
20+
if (args.Outcome.Result is not { StatusCode: HttpStatusCode.TooManyRequests } response)
21+
return ValueTask.FromResult<TimeSpan?>(null);
2022

21-
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
22-
.Handle<HttpRequestException>()
23-
.HandleResult(response => response.StatusCode
24-
is >= HttpStatusCode.InternalServerError
25-
or HttpStatusCode.RequestTimeout
26-
or HttpStatusCode.TooManyRequests
27-
),
23+
var delay = IsQuotaExhausted(response.Headers, "X-RL-Daily-Remaining")
24+
? GetRateLimitDelay(response.Headers, "X-RL-Hourly-Remaining", "X-RL-Hourly-Reset", TimeSpan.FromHours(1)) ?? TimeSpan.FromSeconds(1)
25+
: TimeSpan.FromSeconds(1);
2826

29-
DelayGenerator = static args =>
30-
{
31-
if (args.Outcome.Result is not { StatusCode: HttpStatusCode.TooManyRequests } response)
32-
return ValueTask.FromResult<TimeSpan?>(null);
33-
34-
var delay = GetRateLimitDelay(response.Headers, "X-RL-Daily-Remaining", "X-RL-Daily-Reset", TimeSpan.FromDays(1)) ??
35-
GetRateLimitDelay(response.Headers, "X-RL-Hourly-Remaining", "X-RL-Hourly-Reset", TimeSpan.FromHours(1)) ??
36-
TimeSpan.FromSeconds(1);
37-
38-
return ValueTask.FromResult<TimeSpan?>(delay);
39-
},
27+
return ValueTask.FromResult<TimeSpan?>(delay);
4028
};
4129
});
4230

31+
public static IHttpStandardResiliencePipelineBuilder AddCustomResilienceHandler(this IHttpClientBuilder builder) => builder.AddStandardResilienceHandler(options =>
32+
{
33+
options.Retry = CreateRetryOptions();
34+
});
35+
36+
private static HttpRetryStrategyOptions CreateRetryOptions() => new()
37+
{
38+
MaxRetryAttempts = 5,
39+
BackoffType = DelayBackoffType.Exponential,
40+
UseJitter = true,
41+
Delay = TimeSpan.FromSeconds(1),
42+
43+
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
44+
.Handle<HttpRequestException>()
45+
.HandleResult(response => response.StatusCode
46+
is >= HttpStatusCode.InternalServerError
47+
or HttpStatusCode.RequestTimeout
48+
or HttpStatusCode.TooManyRequests
49+
),
50+
};
51+
52+
private static bool IsQuotaExhausted(HttpResponseHeaders headers, string remainingKey) =>
53+
headers.TryGetValues(remainingKey, out var rem) && int.TryParse(rem.FirstOrDefault(), out var remVal) && remVal == 0;
54+
4355
private static TimeSpan? GetRateLimitDelay(HttpResponseHeaders headers, string remainingKey, string resetKey, TimeSpan maxDelay)
4456
{
45-
if (headers.TryGetValues(remainingKey, out var rem) && int.TryParse(rem.FirstOrDefault(), out var remVal) && remVal == 0 &&
57+
if (IsQuotaExhausted(headers, remainingKey) &&
4658
headers.TryGetValues(resetKey, out var res) && DateTime.TryParse(res.FirstOrDefault(), out var resTime))
4759
{
4860
var delay = resTime - DateTime.UtcNow;
@@ -51,23 +63,4 @@ or HttpStatusCode.TooManyRequests
5163

5264
return null;
5365
}
54-
55-
public static IHttpStandardResiliencePipelineBuilder AddCustomResilienceHandler(this IHttpClientBuilder builder) => builder.AddStandardResilienceHandler(options =>
56-
{
57-
options.Retry = new HttpRetryStrategyOptions
58-
{
59-
MaxRetryAttempts = 5,
60-
BackoffType = DelayBackoffType.Exponential,
61-
UseJitter = true,
62-
Delay = TimeSpan.FromSeconds(1),
63-
64-
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
65-
.Handle<HttpRequestException>()
66-
.HandleResult(response => response.StatusCode
67-
is >= HttpStatusCode.InternalServerError
68-
or HttpStatusCode.RequestTimeout
69-
or HttpStatusCode.TooManyRequests
70-
),
71-
};
72-
});
7366
}

src/BUTR.NexusModsStats/Extensions/ModVersionExtensions.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,11 @@ public static class ModVersionExtensions
1111
{
1212
public static WebApplicationBuilder AddModVersionEndpoint(this WebApplicationBuilder builder)
1313
{
14-
var assemblyName = typeof(ModVersionExtensions).Assembly.GetName();
15-
var userAgent = $"{assemblyName.Name ?? "ERROR"} v{assemblyName.Version?.ToString() ?? "ERROR"} (github.com/BUTR)";
16-
1714
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IEndpointDefinition, ModVersionShieldsEndpointDefinition>());
1815
builder.Services.AddHttpClient<INexusModsApiClient, NexusModsApiClient>().ConfigureHttpClient((_, client) =>
1916
{
2017
client.BaseAddress = new Uri("https://api.nexusmods.com/");
21-
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
18+
client.DefaultRequestHeaders.Add("User-Agent", HttpUtils.UserAgent);
2219
}).AddHttpMessageHandler<NexusModsAuthorizationHandler>().AddNexusModsResilienceHandler();
2320
builder.Services.AddTransient<NexusModsAuthorizationHandler>();
2421

@@ -29,14 +26,14 @@ public class ModVersionShieldsEndpointDefinition : IEndpointDefinition
2926
{
3027
public void RegisterEndpoints(WebApplication app)
3128
{
32-
app.MapGet("/mod-version", async (
29+
app.MapGet("/mod-version", static async (
3330
[FromQuery] string gameId,
3431
[FromQuery] string modId,
3532
[FromServices] INexusModsApiClient apiClient,
3633
CancellationToken ct) =>
3734
{
38-
if (string.IsNullOrWhiteSpace(gameId) || string.IsNullOrWhiteSpace(modId))
39-
return Results.Ok(ShieldsResponseBody.Error("Version", "Invalid 'gameId' or 'modId'!"));
35+
if (!RequestValidation.IsValidId(gameId) || !RequestValidation.IsValidId(modId))
36+
return ShieldsResponseBody.Error("Version", "Invalid 'gameId' or 'modId'!");
4037

4138
var response = await apiClient.GetModAsync(gameId, modId, ct);
4239

src/BUTR.NexusModsStats/Extensions/UptimeKumaExtensions.cs

Lines changed: 50 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using BUTR.NexusModsStats.Options;
22
using BUTR.NexusModsStats.Services;
3+
using BUTR.NexusModsStats.Utils;
34

5+
using Microsoft.Extensions.Caching.Distributed;
46
using Microsoft.Extensions.DependencyInjection.Extensions;
57
using Microsoft.Extensions.Diagnostics.HealthChecks;
68
using Microsoft.Extensions.Options;
@@ -14,26 +16,24 @@ public static partial class UptimeKumaExtensions
1416
{
1517
public static WebApplicationBuilder AddUptimeKuma(this WebApplicationBuilder builder)
1618
{
17-
var assemblyName = typeof(DownloadsExtensions).Assembly.GetName();
18-
var userAgent = $"{assemblyName.Name ?? "ERROR"} v{assemblyName.Version?.ToString() ?? "ERROR"} (github.com/BUTR)";
19-
2019
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IEndpointDefinition, UptimeKumaEndpointDefinition>());
2120
builder.Services.AddHttpClient<IHealthCheckPublisher, UptimeKumaHealthCheckPublisher>().ConfigureHttpClient((sp, client) =>
2221
{
2322
var options = sp.GetRequiredService<IOptions<UptimeKumaOptions>>().Value;
2423

2524
if (Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri))
2625
client.BaseAddress = uri;
27-
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
26+
client.DefaultRequestHeaders.Add("User-Agent", HttpUtils.UserAgent);
2827
});
2928
builder.Services.AddHttpClient<NexusModsApiHealthCheck>().ConfigureHttpClient((_, client) =>
3029
{
3130
client.BaseAddress = new Uri("https://nexusmods.statuspage.io/");
32-
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
31+
client.DefaultRequestHeaders.Add("User-Agent", HttpUtils.UserAgent);
3332
client.Timeout = TimeSpan.FromSeconds(3);
3433
});
3534
builder.Services.AddHealthChecks()
36-
.AddCheck<NexusModsApiHealthCheck>("NexusModsApi");
35+
.AddCheck<NexusModsApiHealthCheck>("NexusModsApi")
36+
.AddCheck<DistributedCacheHealthCheck>("Cache");
3737

3838
return builder;
3939
}
@@ -74,39 +74,45 @@ public void RegisterEndpoints(WebApplication app)
7474
}
7575
}
7676

77+
public sealed class DistributedCacheHealthCheck : IHealthCheck
78+
{
79+
private static readonly DistributedCacheEntryOptions Expiration = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) };
80+
81+
private readonly IDistributedCache _cache;
82+
83+
public DistributedCacheHealthCheck(IDistributedCache cache)
84+
{
85+
_cache = cache;
86+
}
87+
88+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
89+
{
90+
try
91+
{
92+
await _cache.SetStringAsync("healthz", "ok", Expiration, ct);
93+
return HealthCheckResult.Healthy();
94+
}
95+
catch (Exception e)
96+
{
97+
return HealthCheckResult.Unhealthy("Distributed cache is not available", e);
98+
}
99+
}
100+
}
101+
77102
public partial class NexusModsApiHealthCheck : IHealthCheck
78103
{
79104
[JsonSerializable(typeof(Components))]
80-
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
81105
public partial class NexusModsStatusJsonSerializerContext : JsonSerializerContext;
82106

107+
// Only the fields that are actually used are declared - extra JSON properties are ignored,
108+
// and loosely-typed members (e.g. 'object group_id') would break AOT serialization
83109
public record Component(
84-
[property: JsonPropertyName("id")] string id,
85-
[property: JsonPropertyName("name")] string name,
86-
[property: JsonPropertyName("status")] string status,
87-
[property: JsonPropertyName("created_at")] DateTime created_at,
88-
[property: JsonPropertyName("updated_at")] DateTime updated_at,
89-
[property: JsonPropertyName("position")] int position,
90-
[property: JsonPropertyName("description")] string description,
91-
[property: JsonPropertyName("showcase")] bool showcase,
92-
[property: JsonPropertyName("start_date")] string start_date,
93-
[property: JsonPropertyName("group_id")] object group_id,
94-
[property: JsonPropertyName("page_id")] string page_id,
95-
[property: JsonPropertyName("group")] bool group,
96-
[property: JsonPropertyName("only_show_if_degraded")] bool only_show_if_degraded
97-
);
98-
99-
public record Page(
100-
[property: JsonPropertyName("id")] string id,
101-
[property: JsonPropertyName("name")] string name,
102-
[property: JsonPropertyName("url")] string url,
103-
[property: JsonPropertyName("time_zone")] string time_zone,
104-
[property: JsonPropertyName("updated_at")] DateTime updated_at
110+
[property: JsonPropertyName("name")] string Name,
111+
[property: JsonPropertyName("status")] string Status
105112
);
106113

107114
public record Components(
108-
[property: JsonPropertyName("page")] Page page,
109-
[property: JsonPropertyName("components")] IReadOnlyList<Component> components
115+
[property: JsonPropertyName("components")] IReadOnlyList<Component> ComponentList
110116
);
111117

112118
private readonly HttpClient _httpClient;
@@ -118,15 +124,22 @@ public NexusModsApiHealthCheck(HttpClient httpClient)
118124

119125
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
120126
{
121-
var response = await _httpClient.GetFromJsonAsync("api/v2/components.json", NexusModsStatusJsonSerializerContext.Default.Components, ct);
122-
var apiComponent = response?.components.FirstOrDefault(x => x.name == "API");
123-
if (apiComponent is null)
124-
return HealthCheckResult.Unhealthy("NexusMods API Status not available");
127+
try
128+
{
129+
var response = await _httpClient.GetFromJsonAsync("api/v2/components.json", NexusModsStatusJsonSerializerContext.Default.Components, ct);
130+
var apiComponent = response?.ComponentList.FirstOrDefault(x => x.Name == "API");
131+
if (apiComponent is null)
132+
return HealthCheckResult.Unhealthy("NexusMods API Status not available");
125133

126-
if (apiComponent.status != "operational")
127-
return HealthCheckResult.Degraded($"NexusMods API Status is {apiComponent.status}");
134+
if (apiComponent.Status != "operational")
135+
return HealthCheckResult.Degraded($"NexusMods API Status is {apiComponent.Status}");
128136

129-
return HealthCheckResult.Healthy();
137+
return HealthCheckResult.Healthy();
138+
}
139+
catch (Exception e) when (e is not OperationCanceledException || !ct.IsCancellationRequested)
140+
{
141+
return HealthCheckResult.Unhealthy("NexusMods API Status not available", e);
142+
}
130143
}
131144
}
132145
}

0 commit comments

Comments
 (0)