Author SHA1 Message Date
Josh-HeapsandClaude Opus 5 392b9ec212 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) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
@@ -1,4 +1,4 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using JoshHeaps.Net.Models; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger; private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5); private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(); private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> 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; return (T?)entry.Value;
var result = await factory(); var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null) if (result is not null)
{ {
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl)); _cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
} }
else if (entry is not null)
return entry is null ? null : ServeStale<T>(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<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{ {
// API unreachable serve stale cache _logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
_logger.LogWarning("Serving stale cache for key {Key}", key);
return (T?)entry.Value; 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() public void ClearCache()