Blog/Services/WarframeDropService.cs 4.7 K · 115 lines · raw · history

1 using System.Diagnostics;
2 using Blog.Models;
3 using Microsoft.Extensions.Options;
4
5 namespace Blog.Services;
6
7 /// <summary>Where the drop tables come from, and how long a copy of them is good for.</summary>
8 public sealed class WarframeOptions
9 {
10 public const string Section = "Warframe";
11
12 /// <summary>
13 /// DE's drop table page. It redirects to a CDN copy whose path is a content hash, so the
14 /// redirect is followed rather than hardcoded.
15 /// </summary>
16 public string DropTablesUrl { get; set; } = "https://www.warframe.com/droptables";
17
18 /// <summary>
19 /// How long a parsed copy is served for. DE republish the page a few times a month at most.
20 /// </summary>
21 public TimeSpan CacheFor { get; set; } = TimeSpan.FromHours(12);
22
23 /// <summary>How long to wait on the download before giving up.</summary>
24 public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
25 }
26
27 /// <summary>
28 /// Keeps one parsed copy of the Warframe drop tables in memory and hands it to whoever asks.
29 /// </summary>
30 /// <remarks>
31 /// Not <c>HybridCache</c>, which the rest of this site uses: the parsed tables are tens of
32 /// thousands of records, and HybridCache serializes what it stores. One value on a timer, so a
33 /// field behind a gate is the whole mechanism.
34 ///
35 /// That only works because the service is a singleton. A typed
36 /// <c>AddHttpClient&lt;WarframeDropService&gt;()</c> registration would make it transient, and
37 /// every request would download and parse 4 MB into a cache nobody reads twice.
38 ///
39 /// A failed refresh keeps the copy it already had, and reports failure only with nothing at all.
40 /// </remarks>
41 public sealed class WarframeDropService(
42 IHttpClientFactory clients,
43 IOptions<WarframeOptions> options,
44 ILogger<WarframeDropService> logger)
45 {
46 /// <summary>The named client Program.cs configures for this service.</summary>
47 public const string ClientName = "warframe";
48
49 private readonly WarframeOptions _options = options.Value;
50 private readonly SemaphoreSlim _gate = new(1, 1);
51
52 private DropTables? _tables;
53 private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
54
55 public async Task<DropTableStatus> GetTablesAsync(CancellationToken cancellationToken = default)
56 {
57 if (_tables is { } fresh && DateTimeOffset.UtcNow < _expiresAt)
58 {
59 return DropTableStatus.Ready(fresh);
60 }
61
62 await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
63 try
64 {
65 // Someone else may have refreshed while this request queued for the gate.
66 if (_tables is { } current && DateTimeOffset.UtcNow < _expiresAt)
67 {
68 return DropTableStatus.Ready(current);
69 }
70
71 var tables = await FetchAsync(cancellationToken).ConfigureAwait(false);
72 _tables = tables;
73 _expiresAt = DateTimeOffset.UtcNow + _options.CacheFor;
74 return DropTableStatus.Ready(tables);
75 }
76 catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException)
77 {
78 logger.LogWarning(ex, "Could not read the Warframe drop tables from {Url}", _options.DropTablesUrl);
79
80 // Back off well short of the normal life: a site that is down should not be hit on
81 // every page view, and a blip should not cost half a day.
82 _expiresAt = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5);
83
84 return DropTableStatus.Failed(
85 $"Could not reach {_options.DropTablesUrl} ({ex.Message})",
86 _tables);
87 }
88 finally
89 {
90 _gate.Release();
91 }
92 }
93
94 private async Task<DropTables> FetchAsync(CancellationToken cancellationToken)
95 {
96 using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
97 timeout.CancelAfter(_options.Timeout);
98
99 // A client per fetch rather than one held for the life of the singleton. The factory pools
100 // the handler underneath; a long-lived HttpClient would pin a stale CDN address.
101 using var client = clients.CreateClient(ClientName);
102
103 var stopwatch = Stopwatch.StartNew();
104 var html = await client.GetStringAsync(_options.DropTablesUrl, timeout.Token).ConfigureAwait(false);
105 var downloaded = stopwatch.ElapsedMilliseconds;
106
107 var tables = DropTableParser.Parse(html, DateTimeOffset.UtcNow);
108
109 logger.LogInformation(
110 "Read the Warframe drop tables ({Update}): {Bytes} bytes in {Downloaded} ms, {Items} items parsed in {Parsed} ms",
111 tables.LastUpdate, html.Length, downloaded, tables.Names.Count, stopwatch.ElapsedMilliseconds - downloaded);
112
113 return tables;
114 }
115 }