From 392b9ec2125300f4b57ec29ee1a87ad65eef05ec Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Wed, 26 Aug 2026 17:22:51 -0600 Subject: [PATCH] Bound how long the blog cache serves stale entries A failed fetch fell back to the cached value with no limit, so an unreachable blog API kept the post list rendering while every uncached slug returned 404. Stale entries now survive an hour past their TTL, then get dropped and logged at error so the outage surfaces the same way for every key. Co-Authored-By: Claude Opus 5 (1M context) --- .../Services/Implementations/BlogService.cs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/JoshHeaps.Net/Services/Implementations/BlogService.cs b/JoshHeaps.Net/Services/Implementations/BlogService.cs index a6ecad2..61e4e79 100644 --- a/JoshHeaps.Net/Services/Implementations/BlogService.cs +++ b/JoshHeaps.Net/Services/Implementations/BlogService.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text.Json; using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; @@ -10,6 +10,7 @@ public class BlogService : IBlogService private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5); + private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1); private readonly ConcurrentDictionary _cache = new(); @@ -65,7 +66,9 @@ public class BlogService : IBlogService private async Task GetCachedAsync(string key, Func> factory) where T : class { - if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow) + _cache.TryGetValue(key, out var entry); + + if (entry is not null && entry.ExpiresAt > DateTime.UtcNow) return (T?)entry.Value; var result = await factory(); @@ -73,15 +76,26 @@ public class BlogService : IBlogService if (result is not null) { _cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl)); + return result; } - else if (entry is not null) + + return entry is null ? null : ServeStale(key, entry); + } + + // Stale entries are dropped once the grace window closes so that a dead API fails the same + // way for every key. Serving them indefinitely let a cached post list render next to 404s on + // the posts themselves, which reads as a site bug rather than an outage. + private T? ServeStale(string key, CacheEntry entry) where T : class + { + if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow) { - // API unreachable — serve stale cache - _logger.LogWarning("Serving stale cache for key {Key}", key); + _logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key); return (T?)entry.Value; } - return result; + _cache.TryRemove(key, out _); + _logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key); + return null; } public void ClearCache() -- 2.43.0