Skip to content

Commit 43ef985

Browse files
committed
refactor: guard reddit client DI, self-heal wedged feed job, centralize user-agent setting
1 parent 4dd652c commit 43ef985

8 files changed

Lines changed: 99 additions & 15 deletions

File tree

api/API/Discovery/RedditFeedClient.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using API.Services;
12
using System.Xml.Linq;
23

34
namespace API.Discovery;
@@ -8,10 +9,8 @@ public class RedditFeedClient(HttpClient http) : IRedditFeedClient
89
/// <summary>Reddit's recommended User-Agent shape (platform:appid (comment)).</summary>
910
public const string UserAgent = "kenku:discovery (self-hosted manga manager)";
1011

11-
/// <summary>Sets the reddit User-Agent as a raw header — the typed <c>UserAgent.ParseAdd</c> rejects
12-
/// this otherwise-valid value and throws at client construction.</summary>
13-
public static void ConfigureClient(HttpClient client) =>
14-
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", UserAgent);
12+
/// <summary>Sets the reddit User-Agent (a raw header — see <see cref="API.Services.HttpClientExtensions.SetUserAgent"/>).</summary>
13+
public static void ConfigureClient(HttpClient client) => client.SetUserAgent(UserAgent);
1514

1615

1716
public async Task<List<DiscoveryEntry>> GetHotAsync(string subreddit, int limit, CancellationToken ct)

api/API/HttpRequesters/HttpRequester.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using API.HttpRequesters.Interfaces;
2+
using API.Services;
23
using System.Net;
34
using log4net;
45

@@ -19,8 +20,8 @@ public HttpRequester(RateLimitHandler rateLimitHandler, KenkuSettings settings)
1920
{
2021
Timeout = Timeout.InfiniteTimeSpan,
2122
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
22-
DefaultRequestHeaders = { { "User-Agent", settings.UserAgent } }
2323
};
24+
_client.SetUserAgent(settings.UserAgent);
2425
_flareSolverrClient = new FlareSolverrRequester(_client, settings);
2526
}
2627

api/API/JobRuntime/Reconcilers/DiscoveryFeedReconciler.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ public class DiscoveryFeedReconciler(IServiceScopeFactory scopeFactory, IClock c
2020
protected override Task TickAsync(IServiceProvider scope, CancellationToken ct) =>
2121
EnqueueAsync(scope.GetRequiredService<IJobStore>(), clock.UtcNow, ct);
2222

23-
public static Task EnqueueAsync(IJobStore store, DateTime now, CancellationToken ct) =>
24-
store.EnqueueAsync(new Job(RefreshDiscoveryFeedHandler.Type, "{}", now, dedupKey: DedupKey), ct);
23+
public static async Task EnqueueAsync(IJobStore store, DateTime now, CancellationToken ct)
24+
{
25+
// A periodic refresh must not stay wedged: enqueue dedups onto an active job, and a parked
26+
// (NeedsAttention) run counts as active — so a single past hard failure would disable the feed
27+
// forever. Re-arm that parked job instead so the next tick runs it.
28+
if ((await store.GetAllAsync(ct)).FirstOrDefault(j =>
29+
j.DedupKey == DedupKey && j.Status == JobStatus.NeedsAttention) is { } parked)
30+
{
31+
parked.Status = JobStatus.Queued;
32+
parked.Attempts = 0;
33+
parked.ScheduledFor = now;
34+
parked.Error = null;
35+
await store.UpdateAsync(parked, ct);
36+
return;
37+
}
38+
39+
await store.EnqueueAsync(new Job(RefreshDiscoveryFeedHandler.Type, "{}", now, dedupKey: DedupKey), ct);
40+
}
2541
}

api/API/Schema/NotificationsContext/NotificationConnectors/NotificationConnector.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.ComponentModel.DataAnnotations;
22
using System.ComponentModel.DataAnnotations.Schema;
33
using System.Text;
4+
using API.Services;
45
using log4net;
56
using Microsoft.EntityFrameworkCore;
67
using Newtonsoft.Json;
@@ -28,10 +29,7 @@ public class NotificationConnector(string name, string url, Dictionary<string, s
2829
internal NotificationConnector(string name, string url, string httpMethod, string body)
2930
: this(name, url, new Dictionary<string, string>(), httpMethod, body) { }
3031

31-
[NotMapped] private readonly HttpClient Client = new()
32-
{
33-
DefaultRequestHeaders = { { "User-Agent", KenkuSettings.DefaultUserAgent } }
34-
};
32+
[NotMapped] private readonly HttpClient Client = new HttpClient().SetUserAgent(KenkuSettings.DefaultUserAgent);
3533

3634
[JsonIgnore] protected ILog Log = LogManager.GetLogger(name);
3735

api/API/Services/HttpClientExtensions.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ public static class HttpClientExtensions
88
/// </summary>
99
public const string KenkuUserAgent = "Kenku/1.0 (+https://github.com/chutch3/kenku)";
1010

11-
/// <summary>Sets the Kenku User-Agent if one isn't already present, then returns the client.</summary>
12-
public static HttpClient WithKenkuUserAgent(this HttpClient client)
11+
/// <summary>
12+
/// Sets the User-Agent as a raw header, replacing any existing one. Raw because the typed
13+
/// <c>DefaultRequestHeaders.UserAgent</c>/<c>Add</c> validate against product-token syntax and
14+
/// throw on otherwise-fine values (a user's custom agent, reddit's "platform:appid (comment)").
15+
/// </summary>
16+
public static HttpClient SetUserAgent(this HttpClient client, string userAgent)
1317
{
14-
if (client.DefaultRequestHeaders.UserAgent.Count == 0)
15-
client.DefaultRequestHeaders.UserAgent.ParseAdd(KenkuUserAgent);
18+
client.DefaultRequestHeaders.Remove("User-Agent");
19+
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent);
1620
return client;
1721
}
22+
23+
/// <summary>Sets the Kenku User-Agent if one isn't already present, then returns the client.</summary>
24+
public static HttpClient WithKenkuUserAgent(this HttpClient client) =>
25+
client.DefaultRequestHeaders.UserAgent.Count == 0 ? client.SetUserAgent(KenkuUserAgent) : client;
1826
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using API.Discovery;
2+
using API.Extensions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Xunit;
5+
6+
namespace API.Tests.Unit.Extensions;
7+
8+
/// <summary>
9+
/// The discovery HTTP clients must construct through the real DI graph. The reddit client's
10+
/// User-Agent was once set with the strict typed parser, which threw at construction — invisible to
11+
/// tests that stub <see cref="IRedditFeedClient"/> (the feed end-to-end test does), so it only
12+
/// surfaced when the live job resolved the client. This resolves the real registration instead.
13+
/// </summary>
14+
public class DiscoveryRegistrationTests
15+
{
16+
[Fact]
17+
public void RedditFeedClient_ResolvesFromTheRealContainer_WithoutThrowing()
18+
{
19+
using ServiceProvider provider = new ServiceCollection().AddKenkuServices().BuildServiceProvider();
20+
21+
// Resolution builds the typed HttpClient and runs the registration's configure callback —
22+
// the exact step that threw on the bad User-Agent.
23+
var client = provider.GetRequiredService<IRedditFeedClient>();
24+
25+
Assert.NotNull(client);
26+
}
27+
}

api/Tests/Unit/HttpRequesters/HttpRequesterTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ private static HttpRequester CreateClient(Func<HttpRequestMessage, HttpResponseM
1414
return new HttpRequester(rateLimit, settings);
1515
}
1616

17+
[Fact]
18+
public void Constructs_WithACustomUserAgent_TheTypedParserWouldReject()
19+
{
20+
// Users can set any User-Agent; a reddit-style value ("kenku:discovery (…)") trips the typed
21+
// header parser, which would throw at requester construction and break every outbound call.
22+
var settings = new KenkuSettings { AppData = Path.GetTempPath() };
23+
settings.SetUserAgent("kenku:discovery (self-hosted manga manager)");
24+
var rateLimit = new RateLimitHandler(settings, new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)));
25+
26+
var ex = Record.Exception(() => new HttpRequester(rateLimit, settings));
27+
28+
Assert.Null(ex);
29+
}
30+
1731
[Fact]
1832
public async Task MakeRequest_Success_ReturnsResponse()
1933
{

api/Tests/Unit/JobRuntime/DiscoveryFeedReconcilerTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using API.JobRuntime;
22
using API.JobRuntime.Handlers;
33
using API.JobRuntime.Reconcilers;
4+
using API.Schema.JobsContext;
45
using Xunit;
56

67
namespace API.Tests.Unit.JobRuntime;
@@ -20,4 +21,24 @@ public async Task Tick_EnqueuesOneDedupedRefreshJob()
2021
Assert.Equal(RefreshDiscoveryFeedHandler.Type, job.Type);
2122
Assert.Equal(DiscoveryFeedReconciler.DedupKey, job.DedupKey);
2223
}
24+
25+
[Fact]
26+
public async Task Tick_ReArmsAParkedRefreshJob_SoAHardFailureCannotWedgeTheFeedForever()
27+
{
28+
var store = new InMemoryJobStore();
29+
// A prior run failed its way to NeedsAttention; dedup would otherwise coalesce onto it forever.
30+
var parked = await store.EnqueueAsync(new Job(RefreshDiscoveryFeedHandler.Type, "{}", DateTime.UtcNow,
31+
dedupKey: DiscoveryFeedReconciler.DedupKey));
32+
parked.Status = JobStatus.NeedsAttention;
33+
parked.Attempts = 5;
34+
parked.Error = "boom";
35+
await store.UpdateAsync(parked);
36+
37+
await DiscoveryFeedReconciler.EnqueueAsync(store, DateTime.UtcNow, default);
38+
39+
var job = Assert.Single(await store.GetAllAsync());
40+
Assert.Equal(JobStatus.Queued, job.Status);
41+
Assert.Equal(0, job.Attempts);
42+
Assert.Null(job.Error);
43+
}
2344
}

0 commit comments

Comments
 (0)