Files
JoshHeaps.Net/JoshHeaps.Net/Services/Implementations/BlogService.cs
T
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

109 lines
3.6 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
public class BlogService : IBlogService
{
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
{
_httpClient = httpClientFactory.CreateClient("BlogApi");
_logger = logger;
}
public async Task<List<BlogPost>> GetAllPostsAsync()
{
return await GetCachedAsync("all_posts",
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
return await GetCachedAsync($"post_{slug}",
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
return await GetCachedAsync($"tag_{tag}",
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
}
private async Task<T?> FetchAsync<T>(string path) where T : class
{
try
{
var response = await _httpClient.GetAsync(path);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
return null;
}
}
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
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)
{
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
_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()
{
_cache.Clear();
_logger.LogInformation("Blog cache cleared");
}
private record CacheEntry(object Value, DateTime ExpiresAt);
}