From 2470486b26f9d5078355ce4235a3a1cd38fda912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ruiz=20Ad=C3=A1n?= Date: Tue, 21 Jul 2026 18:10:46 +0200 Subject: [PATCH] feat(caching): Vary-aware variant caching and conditional-request coalescing safety (v2.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjacent correctness fixes in the caching/coalescing paths. #1 Vary: cache multiple representations (RFC 9111 §4.1) Responses with a Vary header are stored under a secondary cache key derived from the request's values for the Vary fields, with a small IsVaryMarker entry at the primary key recording which headers to vary on. Lookups follow the marker to the matching variant. Previously only one representation could be cached per URL, so a Vary: Accept-Encoding resource requested by a gzip client then an identity client kept overwriting the single entry — content- negotiated endpoints never got variant hits. Works for both MemoryCacheStore and DistributedCacheStore (marker + variants serialize like any entry). Vary: * stays uncacheable. All writes route through WriteEntryAsync so revalidation refreshes the variant key, not the primary. #2 Conditional requests no longer coalesce with non-conditional ones RequestKey now folds present conditional headers (If-None-Match, If-Modified-Since, If-Match, If-Unmodified-Since, If-Range) into the coalescing key. Previously a plain GET and an If-None-Match revalidation for the same URL could collapse into one execution, handing a caller that never sent a validator a bodyless 304. Identical revalidations still share a key, so a revalidation storm is still collapsed to one origin call. The common non-conditional path keeps its method+URL fast path. - Add CacheEntry.IsVaryMarker (+ JSON converter read/write). - New tests: VaryVariantCachingTests (variant coexistence, isolation, revalidation write-back, distributed store, Vary: *) and ConditionalRequestCoalescingTests (key discrimination + concurrency proving a non-conditional caller never receives a 304). 343 tests pass, zero warnings. Co-Authored-By: Claude Fable 5 --- README.md | 4 + .../Caching/CacheEntryJsonConverterTests.cs | 4 +- .../Caching/VaryVariantCachingTests.cs | 213 ++++++++++++++++++ .../ConditionalRequestCoalescingTests.cs | 163 ++++++++++++++ Stampede.Http/Caching/CacheEntry.cs | 9 + .../Caching/CacheEntryJsonConverter.cs | 8 +- Stampede.Http/Caching/CachingMiddleware.cs | 128 ++++++++++- Stampede.Http/Coalescing/RequestKey.cs | 74 +++++- Stampede.Http/Stampede.Http.csproj | 4 +- 9 files changed, 592 insertions(+), 15 deletions(-) create mode 100644 Stampede.Http.Tests/Caching/VaryVariantCachingTests.cs create mode 100644 Stampede.Http.Tests/Coalescing/ConditionalRequestCoalescingTests.cs diff --git a/README.md b/README.md index a642721..dcdd866 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,10 @@ MIT — see [LICENSE](LICENSE). ## Changelog +### v2.2.0 +- **Vary: multiple representations are cached simultaneously (RFC 9111 §4.1).** Responses carrying a `Vary` header are now stored under a secondary cache key derived from the request's values for the Vary fields, with a small marker at the primary key recording which headers to vary on. Previously only one representation could be cached per URL — a `Vary: Accept-Encoding` resource requested by a gzip client and then an identity client kept overwriting the single entry, so content-negotiated endpoints never got variant cache hits. Works with both `MemoryCacheStore` and `DistributedCacheStore`; `Vary: *` remains uncacheable. +- **Conditional requests are no longer coalesced with non-conditional ones.** The coalescing key now folds in any conditional request headers (`If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`, `If-Range`). Previously a plain `GET` and an `If-None-Match` revalidation for the same URL could collapse into one execution, letting a caller that never sent a validator receive a bodyless `304`. Identical revalidations still coalesce, so a revalidation storm is still collapsed into a single origin call. + ### v2.1.0 - **`RevalidationGraceSeconds`** (default `300`) — entries carrying an `ETag` or `Last-Modified` validator are now retained in the cache store for a grace period beyond their freshness lifetime and stale windows. Previously a response with `max-age=N` and no stale windows was physically evicted exactly at expiry, so the conditional-revalidation path (`If-None-Match` / `If-Modified-Since` → `304`) could never fire with the default store — every expiry was a full refetch. Applies to both `MemoryCacheStore` and `DistributedCacheStore`; entries without a validator are unaffected. Set to `0` to restore the previous evict-at-expiry behavior. Like `MaxCacheSize`, this is a structural option read at registration time. diff --git a/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs b/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs index fd6e9f6..36637a9 100644 --- a/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs +++ b/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs @@ -39,7 +39,8 @@ public void RoundTrip_AllFields_PreservesValues() }, StaleIfErrorSeconds = 300, StaleWhileRevalidateSeconds = 60, - MustRevalidate = true + MustRevalidate = true, + IsVaryMarker = true }; string json = JsonSerializer.Serialize(original, CacheEntryJsonContext.Default.CacheEntry); @@ -58,6 +59,7 @@ public void RoundTrip_AllFields_PreservesValues() restored.StaleIfErrorSeconds.Should().Be(original.StaleIfErrorSeconds); restored.StaleWhileRevalidateSeconds.Should().Be(original.StaleWhileRevalidateSeconds); restored.MustRevalidate.Should().Be(original.MustRevalidate); + restored.IsVaryMarker.Should().Be(original.IsVaryMarker); } // ── Optional fields default to null/default when absent ────────────────── diff --git a/Stampede.Http.Tests/Caching/VaryVariantCachingTests.cs b/Stampede.Http.Tests/Caching/VaryVariantCachingTests.cs new file mode 100644 index 0000000..c7f1907 --- /dev/null +++ b/Stampede.Http.Tests/Caching/VaryVariantCachingTests.cs @@ -0,0 +1,213 @@ +using Stampede.Http.Caching; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using System.Net; +using System.Net.Http.Headers; + +namespace Stampede.Http.Tests.Caching; + +/// +/// Verifies that the middleware stores and serves multiple representations of the same URL keyed on their +/// Vary header values (RFC 9111 §4.1 secondary cache keys). Before variant support, a second +/// representation overwrote the first at the shared primary key, so content-negotiated resources could never +/// keep more than one variant cached — every alternation was a full refetch. +/// +public sealed class VaryVariantCachingTests +{ + private readonly DefaultCacheKeyBuilder _keyBuilder = new(); + + private static CachingMiddleware BuildPipeline( + ICacheStore store, + Func handler, + CacheOptions? options = null) + { + return new CachingMiddleware(store, new DefaultCacheKeyBuilder(), + options ?? new CacheOptions { DefaultTtl = TimeSpan.FromMinutes(5) }) + { + InnerHandler = new StubTransport(handler) + }; + } + + private static HttpRequestMessage Req(string url, string? acceptLanguage) + { + HttpRequestMessage req = new(HttpMethod.Get, url); + if (acceptLanguage is not null) + { + req.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage); + } + + return req; + } + + /// Origin that varies on Accept-Language and echoes the negotiated language in the body. + private static HttpResponseMessage VaryingByLanguage(HttpRequestMessage request) + { + string lang = request.Headers.TryGetValues("Accept-Language", out IEnumerable? v) + ? string.Join(",", v) + : "none"; + + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent($"lang={lang}") }; + r.Headers.Vary.Add("Accept-Language"); + return r; + } + + // ── Multiple variants coexist ──────────────────────────────────────────── + + [Fact] + public async Task TwoVariants_AreCachedIndependently_ThirdAlternatingRequestIsAHit() + { + int callCount = 0; + CachingMiddleware middleware = BuildPipeline( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + req => { callCount++; return VaryingByLanguage(req); }); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/variants"; + + // en → miss (origin call 1) + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + // es → miss, different variant (origin call 2) — must NOT overwrite the en variant + _ = await invoker.SendAsync(Req(url, "es"), TestContext.Current.CancellationToken); + + // en again → this is the regression case: under a single-entry cache the es response would have + // overwritten en, forcing a third origin call. With variant keys, en is still cached. + HttpResponseMessage third = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + + callCount.Should().Be(2, "each language is fetched once; the repeated 'en' request must be a cache hit"); + (await third.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Be("lang=en", + "the cached 'en' variant must be returned, not the 'es' representation"); + } + + [Fact] + public async Task Variants_DoNotCrossContaminate_EachRequestGetsItsOwnRepresentation() + { + CachingMiddleware middleware = BuildPipeline( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + VaryingByLanguage); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/isolation"; + + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(url, "fr"), TestContext.Current.CancellationToken); + + HttpResponseMessage en = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + HttpResponseMessage fr = await invoker.SendAsync(Req(url, "fr"), TestContext.Current.CancellationToken); + + (await en.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Be("lang=en"); + (await fr.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Be("lang=fr"); + } + + [Fact] + public async Task SameVariant_RepeatedRequest_IsServedFromCache() + { + int callCount = 0; + CachingMiddleware middleware = BuildPipeline( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + req => { callCount++; return VaryingByLanguage(req); }); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/same"; + + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + + callCount.Should().Be(1, "identical Vary values must be served from the same variant"); + } + + [Fact] + public async Task VaryStar_IsNeverServedFromCache() + { + int callCount = 0; + CachingMiddleware middleware = BuildPipeline( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + req => + { + callCount++; + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent("data") }; + r.Headers.Vary.Add("*"); + return r; + }); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/star"; + + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + + callCount.Should().Be(2, "Vary: * must never be served from cache"); + } + + // ── Variant revalidation writes back to the variant key ────────────────── + + [Fact] + public async Task StaleVariantWithETag_Revalidates_AndRefreshesTheCorrectVariant() + { + int originCalls = 0; + int conditionalCalls = 0; + + CachingMiddleware middleware = BuildPipeline( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + req => + { + if (req.Headers.IfNoneMatch.Count > 0) + { + conditionalCalls++; + HttpResponseMessage nm = new(HttpStatusCode.NotModified); + nm.Headers.ETag = new EntityTagHeaderValue("\"en-v1\""); + return nm; + } + + originCalls++; + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent("lang=en") }; + r.Headers.Vary.Add("Accept-Language"); + r.Headers.ETag = new EntityTagHeaderValue("\"en-v1\""); + r.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.Zero }; + return r; + }); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/reval"; + + // First request stores the en variant (immediately stale via max-age=0). + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + // Second request finds the stale en variant and revalidates it conditionally (304) — not a full refetch. + HttpResponseMessage second = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + + originCalls.Should().Be(1, "the variant must be revalidated conditionally, not refetched in full"); + conditionalCalls.Should().Be(1, "the stale en variant must trigger an If-None-Match revalidation"); + second.StatusCode.Should().Be(HttpStatusCode.OK); + (await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Be("lang=en"); + } + + // ── Distributed store variant support (serialization round-trip) ───────── + + [Fact] + public async Task Variants_WorkWithDistributedStore() + { + IDistributedCache backing = new MemoryDistributedCache( + Microsoft.Extensions.Options.Options.Create(new MemoryDistributedCacheOptions())); + int callCount = 0; + + CachingMiddleware middleware = BuildPipeline( + new DistributedCacheStore(backing), + req => { callCount++; return VaryingByLanguage(req); }); + + HttpMessageInvoker invoker = new(middleware); + const string url = "https://api.test/vary/distributed"; + + _ = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(url, "es"), TestContext.Current.CancellationToken); + HttpResponseMessage enAgain = await invoker.SendAsync(Req(url, "en"), TestContext.Current.CancellationToken); + + callCount.Should().Be(2, "variant keying must survive JSON serialization in the distributed store"); + (await enAgain.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Be("lang=en"); + } + + private sealed class StubTransport(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(handler(request)); + } +} diff --git a/Stampede.Http.Tests/Coalescing/ConditionalRequestCoalescingTests.cs b/Stampede.Http.Tests/Coalescing/ConditionalRequestCoalescingTests.cs new file mode 100644 index 0000000..a732cc0 --- /dev/null +++ b/Stampede.Http.Tests/Coalescing/ConditionalRequestCoalescingTests.cs @@ -0,0 +1,163 @@ +using Stampede.Http.Coalescing; +using Stampede.Http.Handlers; +using Stampede.Http.Options; +using FluentAssertions; +using System.Net; +using System.Net.Http.Headers; + +namespace Stampede.Http.Tests.Coalescing; + +/// +/// Verifies that conditional requests (RFC 9110 §13) are never coalesced with non-conditional requests, nor with +/// conditional requests carrying a different validator. Without this, a caller that never sent +/// If-None-Match could receive a bodyless 304 produced for another caller's revalidation. +/// +public sealed class ConditionalRequestCoalescingTests +{ + // ── RequestKey unit tests ───────────────────────────────────────────────── + + [Fact] + public void RequestKey_ConditionalRequest_DiffersFromUnconditional() + { + HttpRequestMessage conditional = Req("https://api.test/res"); + conditional.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage plain = Req("https://api.test/res"); + + RequestKey conditionalKey = RequestKey.Create(conditional, keyHeaders: null); + RequestKey plainKey = RequestKey.Create(plain, keyHeaders: null); + + conditionalKey.Should().NotBe(plainKey, + "a conditional revalidation must not share a coalescing key with a non-conditional request"); + } + + [Fact] + public void RequestKey_SameValidator_AreEqual() + { + HttpRequestMessage a = Req("https://api.test/res"); + a.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage b = Req("https://api.test/res"); + b.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + + RequestKey.Create(a, null).Should().Be(RequestKey.Create(b, null), + "identical revalidations must still coalesce to collapse a revalidation storm"); + } + + [Fact] + public void RequestKey_DifferentValidators_AreNotEqual() + { + HttpRequestMessage a = Req("https://api.test/res"); + a.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage b = Req("https://api.test/res"); + b.Headers.TryAddWithoutValidation("If-None-Match", "\"v2\""); + + RequestKey.Create(a, null).Should().NotBe(RequestKey.Create(b, null), + "different validators may yield different results and must not be coalesced"); + } + + [Fact] + public void RequestKey_IfModifiedSince_IsFoldedIntoKey() + { + HttpRequestMessage conditional = Req("https://api.test/res"); + conditional.Headers.IfModifiedSince = DateTimeOffset.UtcNow; + HttpRequestMessage plain = Req("https://api.test/res"); + + RequestKey.Create(conditional, null).Should().NotBe(RequestKey.Create(plain, null), + "If-Modified-Since is a conditional header and must discriminate the coalescing key"); + } + + [Fact] + public void RequestKey_ConditionalFoldedAlongsideConfiguredKeyHeaders() + { + HttpRequestMessage a = Req("https://api.test/res", ("X-Tenant-Id", "t1")); + a.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage b = Req("https://api.test/res", ("X-Tenant-Id", "t1")); + + RequestKey.Create(a, ["X-Tenant-Id"]).Should().NotBe(RequestKey.Create(b, ["X-Tenant-Id"]), + "conditional headers must be folded in even when explicit CoalesceKeyHeaders are configured"); + } + + // ── CoalescingHandler integration ───────────────────────────────────────── + + [Fact] + public async Task ConditionalAndNonConditional_AreNotCoalesced_AndEachGetsCorrectResponse() + { + // Origin returns 304 for conditional requests and a full 200 for the rest. + ConditionalStub stub = new(delay: TimeSpan.FromMilliseconds(80)); + CoalescerOptions options = new(); + RequestCoalescer coalescer = new(options); + CoalescingHandler handler = new(coalescer, options) { InnerHandler = stub }; + HttpMessageInvoker invoker = new(handler); + + HttpRequestMessage conditional = Req("https://api.test/res"); + conditional.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage plain = Req("https://api.test/res"); + + Task conditionalTask = invoker.SendAsync(conditional, CancellationToken.None); + Task plainTask = invoker.SendAsync(plain, CancellationToken.None); + + await Task.WhenAll(conditionalTask, plainTask); + + stub.CallCount.Should().Be(2, + "a conditional and a non-conditional request for the same URL must execute independently"); + conditionalTask.Result.StatusCode.Should().Be(HttpStatusCode.NotModified, + "the conditional caller must receive the 304 produced for its validator"); + plainTask.Result.StatusCode.Should().Be(HttpStatusCode.OK, + "the non-conditional caller must never receive a 304 it did not ask for"); + (await plainTask.Result.Content.ReadAsStringAsync()).Should().Be("full-body"); + } + + [Fact] + public async Task IdenticalConditionalRequests_AreStillCoalesced() + { + ConditionalStub stub = new(delay: TimeSpan.FromMilliseconds(80)); + CoalescerOptions options = new(); + RequestCoalescer coalescer = new(options); + CoalescingHandler handler = new(coalescer, options) { InnerHandler = stub }; + HttpMessageInvoker invoker = new(handler); + + HttpRequestMessage a = Req("https://api.test/res"); + a.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + HttpRequestMessage b = Req("https://api.test/res"); + b.Headers.TryAddWithoutValidation("If-None-Match", "\"v1\""); + + Task taskA = invoker.SendAsync(a, CancellationToken.None); + Task taskB = invoker.SendAsync(b, CancellationToken.None); + + await Task.WhenAll(taskA, taskB); + + stub.CallCount.Should().Be(1, + "two revalidations with the same validator must collapse into a single origin call"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static HttpRequestMessage Req(string url, params (string Name, string Value)[] headers) + { + HttpRequestMessage req = new(HttpMethod.Get, url); + foreach ((string name, string value) in headers) + { + req.Headers.TryAddWithoutValidation(name, value); + } + + return req; + } + + private sealed class ConditionalStub(TimeSpan delay) : HttpMessageHandler + { + private int _callCount; + public int CallCount => _callCount; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + System.Threading.Interlocked.Increment(ref _callCount); + await Task.Delay(delay, ct); + + if (request.Headers.IfNoneMatch.Count > 0) + { + return new HttpResponseMessage(HttpStatusCode.NotModified); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("full-body") }; + } + } +} diff --git a/Stampede.Http/Caching/CacheEntry.cs b/Stampede.Http/Caching/CacheEntry.cs index 75eaf25..87652b4 100644 --- a/Stampede.Http/Caching/CacheEntry.cs +++ b/Stampede.Http/Caching/CacheEntry.cs @@ -49,6 +49,15 @@ public sealed record CacheEntry /// When , the origin included Cache-Control: immutable (RFC 8246). A fresh immutable entry must never be revalidated, even when the request carries no-cache or a ForceRevalidate policy. public bool Immutable { get; init; } + /// + /// When , this entry is not a stored response but a Vary marker (RFC 9111 §4.1): + /// it lives at the primary cache key and records the that a request must be keyed on, + /// pointing lookups to the correct secondary-key variant. Markers carry an empty and are + /// never served as a response; their expiry/validator metadata mirror the representation they point to purely + /// so their eviction deadline matches it. + /// + public bool IsVaryMarker { get; init; } + /// /// Determines whether the cache entry has expired based on its expiration time. /// diff --git a/Stampede.Http/Caching/CacheEntryJsonConverter.cs b/Stampede.Http/Caching/CacheEntryJsonConverter.cs index d340aae..5d78e46 100644 --- a/Stampede.Http/Caching/CacheEntryJsonConverter.cs +++ b/Stampede.Http/Caching/CacheEntryJsonConverter.cs @@ -26,6 +26,7 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J long staleWhileRevalidateSeconds = 0; bool mustRevalidate = false; bool immutable = false; + bool isVaryMarker = false; if (reader.TokenType != JsonTokenType.StartObject) { @@ -88,6 +89,9 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J case nameof(CacheEntry.Immutable): immutable = reader.GetBoolean(); break; + case nameof(CacheEntry.IsVaryMarker): + isVaryMarker = reader.GetBoolean(); + break; default: reader.Skip(); break; @@ -110,7 +114,8 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J StaleIfErrorSeconds = staleIfErrorSeconds, StaleWhileRevalidateSeconds = staleWhileRevalidateSeconds, MustRevalidate = mustRevalidate, - Immutable = immutable + Immutable = immutable, + IsVaryMarker = isVaryMarker }; } @@ -154,6 +159,7 @@ public override void Write(Utf8JsonWriter writer, CacheEntry value, JsonSerializ writer.WriteNumber(nameof(CacheEntry.StaleWhileRevalidateSeconds), value.StaleWhileRevalidateSeconds); writer.WriteBoolean(nameof(CacheEntry.MustRevalidate), value.MustRevalidate); writer.WriteBoolean(nameof(CacheEntry.Immutable), value.Immutable); + writer.WriteBoolean(nameof(CacheEntry.IsVaryMarker), value.IsVaryMarker); writer.WriteEndObject(); } diff --git a/Stampede.Http/Caching/CachingMiddleware.cs b/Stampede.Http/Caching/CachingMiddleware.cs index a9473ec..90bd401 100644 --- a/Stampede.Http/Caching/CachingMiddleware.cs +++ b/Stampede.Http/Caching/CachingMiddleware.cs @@ -6,6 +6,7 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Http.Headers; +using System.Text; namespace Stampede.Http.Caching; @@ -213,9 +214,122 @@ private async Task StoreAsync(string key, HttpRequestMessage request, HttpRespon Immutable = IsImmutableEntry(cc) }; - await cache.SetAsync(key, entry, ct).ConfigureAwait(false); + await WriteEntryAsync(key, entry, ct).ConfigureAwait(false); } + /// + /// Character separating the primary cache key from the Vary secondary key. U+001F (unit separator) is a + /// control character that cannot appear in a URI or header value, so it never collides with real key content. + /// + private const char VariantKeySeparator = (char)0x1f; + + /// + /// Writes a representation to the store (RFC 9111 §4.1). When the response carries a Vary header, + /// the representation is stored under a secondary (variant) key derived from the request's values for the + /// Vary fields, and a small entry is written at the primary key so + /// future lookups know which request headers to key on. Non-varying responses are stored at the primary key + /// directly. Vary: * stores only a marker (the response is never served from cache). + /// + private async ValueTask WriteEntryAsync(string primaryKey, CacheEntry entry, CancellationToken ct) + { + if (entry.VaryFields.Length == 0) + { + await cache.SetAsync(primaryKey, entry, ct).ConfigureAwait(false); + return; + } + + if (IsVaryStar(entry)) + { + await cache.SetAsync(primaryKey, CreateVaryMarker(entry), ct).ConfigureAwait(false); + return; + } + + string variantKey = BuildVariantKey(primaryKey, entry.VaryFields, + field => entry.VaryValues.TryGetValue(field, out string[]? values) ? values : []); + + await cache.SetAsync(variantKey, entry, ct).ConfigureAwait(false); + await cache.SetAsync(primaryKey, CreateVaryMarker(entry), ct).ConfigureAwait(false); + } + + /// + /// Resolves the stored representation for , following a Vary marker + /// (RFC 9111 §4.1) at to the matching secondary-key variant when present. + /// Returns on a miss or when the marker is Vary: *. + /// + private async ValueTask ResolveEntryAsync(string primaryKey, HttpRequestMessage request, CancellationToken ct) + { + CacheEntry? entry = await cache.GetAsync(primaryKey, ct).ConfigureAwait(false); + + if (entry is null || !entry.IsVaryMarker) + { + return entry; + } + + // Vary: * — the resource is never served from cache (§4.1). + if (IsVaryStar(entry)) + { + return null; + } + + string variantKey = BuildVariantKey(primaryKey, entry.VaryFields, + field => request.Headers.TryGetValues(field, out IEnumerable? values) ? [.. values] : []); + + return await cache.GetAsync(variantKey, ct).ConfigureAwait(false); + } + + /// + /// Builds a Vary secondary cache key by appending the request's normalized values for each Vary field to the + /// primary key (RFC 9111 §4.1). Field names are sorted case-insensitively and values are lower-cased so the + /// key agrees with the case-insensitive comparison performed by . + /// + private static string BuildVariantKey(string primaryKey, string[] varyFields, Func getValues) + { + string[] fields = [.. varyFields]; + Array.Sort(fields, StringComparer.OrdinalIgnoreCase); + + StringBuilder sb = new(primaryKey.Length + 32); + sb.Append(primaryKey); + + foreach (string field in fields) + { + sb.Append(VariantKeySeparator).Append(field.ToLowerInvariant()).Append('='); + + string[] values = getValues(field); + for (int i = 0; i < values.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(values[i].ToLowerInvariant()); + } + } + + return sb.ToString(); + } + + /// + /// Creates a Vary marker for the given representation. The marker carries no body and mirrors the + /// representation's expiry/stale/validator metadata only so its eviction deadline matches the variant it + /// points to; it is never returned as a response. + /// + private static CacheEntry CreateVaryMarker(CacheEntry representation) => new() + { + StatusCode = representation.StatusCode, + Body = [], + Headers = new Dictionary(), + ExpiresAt = representation.ExpiresAt, + StoredAt = representation.StoredAt, + ETag = representation.ETag, + LastModified = representation.LastModified, + VaryFields = representation.VaryFields, + VaryValues = new Dictionary(), + StaleIfErrorSeconds = representation.StaleIfErrorSeconds, + StaleWhileRevalidateSeconds = representation.StaleWhileRevalidateSeconds, + IsVaryMarker = true + }; + private static string[] ExtractVaryFields(HttpResponseMessage response) { return response.Headers.Vary.Count == 0 ? [] : [.. response.Headers.Vary]; @@ -368,7 +482,8 @@ protected override async Task SendAsync(HttpRequestMessage string key = keyBuilder.Build(request); - CacheEntry? entry = await cache.GetAsync(key, ct).ConfigureAwait(false); + // §4.1 — follow a Vary marker to the representation matching this request's Vary values. + CacheEntry? entry = await ResolveEntryAsync(key, request, ct).ConfigureAwait(false); // §4.1 — Vary: * means this response must never be served from cache if (entry is not null && IsVaryStar(entry)) @@ -487,7 +602,8 @@ private async Task HandleHeadAsync(HttpRequestMessage reque // RFC 9110 §9.3.2 — use the GET cache key for HEAD requests string getKey = BuildGetKey(request.RequestUri); - CacheEntry? entry = await cache.GetAsync(getKey, ct).ConfigureAwait(false); + // §4.1 — follow a Vary marker to the representation matching this request's Vary values. + CacheEntry? entry = await ResolveEntryAsync(getKey, request, ct).ConfigureAwait(false); if (entry is not null && IsVaryStar(entry)) { @@ -539,7 +655,7 @@ private async Task HandleHeadAsync(HttpRequestMessage reque StaleWhileRevalidateSeconds = FreshnessCalculator.ExtractStaleWhileRevalidate(revalResponse, Options), MustRevalidate = revalResponse.Headers.CacheControl?.MustRevalidate == true || revalResponse.Headers.CacheControl?.ProxyRevalidate == true }; - await cache.SetAsync(getKey, refreshed, ct).ConfigureAwait(false); + await WriteEntryAsync(getKey, refreshed, ct).ConfigureAwait(false); metrics?.RecordCacheHit(HttpMethod.Head); HttpResponseMessage headRefreshed = CreateResponse(refreshed); headRefreshed.Content = new ByteArrayContent([]); @@ -651,7 +767,7 @@ private async Task RevalidateAsync(string key, CacheEntry e StaleWhileRevalidateSeconds = FreshnessCalculator.ExtractStaleWhileRevalidate(response, Options), MustRevalidate = response.Headers.CacheControl?.MustRevalidate == true || response.Headers.CacheControl?.ProxyRevalidate == true }; - await cache.SetAsync(key, refreshed, ct).ConfigureAwait(false); + await WriteEntryAsync(key, refreshed, ct).ConfigureAwait(false); metrics?.RecordCacheHit(); return CreateResponse(refreshed); } @@ -729,7 +845,7 @@ private void ScheduleBackgroundRevalidation(string key, CacheEntry entry, HttpRe StaleWhileRevalidateSeconds = FreshnessCalculator.ExtractStaleWhileRevalidate(response, Options), MustRevalidate = response.Headers.CacheControl?.MustRevalidate == true || response.Headers.CacheControl?.ProxyRevalidate == true }; - await cache.SetAsync(key, refreshed, CancellationToken.None).ConfigureAwait(false); + await WriteEntryAsync(key, refreshed, CancellationToken.None).ConfigureAwait(false); } else if (IsResponseCacheable(response)) { diff --git a/Stampede.Http/Coalescing/RequestKey.cs b/Stampede.Http/Coalescing/RequestKey.cs index dc6defb..de4c17a 100644 --- a/Stampede.Http/Coalescing/RequestKey.cs +++ b/Stampede.Http/Coalescing/RequestKey.cs @@ -4,6 +4,15 @@ namespace Stampede.Http.Coalescing; internal readonly record struct RequestKey(string Method, string Url, string HeadersKey = "") { + /// + /// Conditional request headers (RFC 9110 §13) that change the meaning of a response — an + /// If-None-Match revalidation may yield a bodyless 304 that a non-conditional caller cannot + /// interpret. These are always folded into the coalescing key so requests with different (or absent) + /// validators are never collapsed into one another, while identical revalidations still coalesce. + /// + private static readonly string[] ConditionalHeaderNames = + ["If-None-Match", "If-Modified-Since", "If-Match", "If-Unmodified-Since", "If-Range"]; + public override string ToString() { return HeadersKey.Length == 0 @@ -19,24 +28,79 @@ public static RequestKey Create(HttpRequestMessage request) /// /// Creates a key from the request, optionally including the values of specific header fields - /// in the key so requests with different header values are coalesced independently. + /// in the key so requests with different header values are coalesced independently. Any conditional + /// request headers present (If-None-Match, If-Modified-Since, etc.) are always folded in, + /// so a conditional revalidation is never coalesced with a non-conditional request for the same URL. /// /// The HTTP request to key. /// - /// Header field names to incorporate into the key. When or empty the - /// key falls back to method + URL only. + /// Additional header field names to incorporate into the key. When or empty and no + /// conditional headers are present, the key falls back to method + URL only. /// public static RequestKey Create(HttpRequestMessage request, IReadOnlyList? keyHeaders) { - if (keyHeaders is null || keyHeaders.Count == 0) + bool hasKeyHeaders = keyHeaders is not null && keyHeaders.Count > 0; + bool hasConditional = HasConditionalHeaders(request); + + if (!hasKeyHeaders && !hasConditional) { return Create(request); } - string headersKey = BuildHeadersKey(request, keyHeaders); + IReadOnlyList effectiveHeaders = hasConditional + ? MergeConditionalHeaders(keyHeaders, request) + : keyHeaders!; + + string headersKey = BuildHeadersKey(request, effectiveHeaders); return new RequestKey(request.Method.Method, request.RequestUri!.AbsoluteUri, headersKey); } + /// Returns when the request carries any conditional header (RFC 9110 §13). + private static bool HasConditionalHeaders(HttpRequestMessage request) + { + foreach (string name in ConditionalHeaderNames) + { + if (request.Headers.Contains(name)) + { + return true; + } + } + + return false; + } + + /// + /// Returns the union of the configured and any conditional headers present on + /// the request, de-duplicated case-insensitively. + /// + private static List MergeConditionalHeaders(IReadOnlyList? keyHeaders, HttpRequestMessage request) + { + List merged = keyHeaders is null ? new(ConditionalHeaderNames.Length) : [.. keyHeaders]; + + foreach (string name in ConditionalHeaderNames) + { + if (request.Headers.Contains(name) && !ContainsIgnoreCase(merged, name)) + { + merged.Add(name); + } + } + + return merged; + } + + private static bool ContainsIgnoreCase(List names, string value) + { + foreach (string name in names) + { + if (string.Equals(name, value, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + /// /// Builds a deterministic string from the listed header values. /// Header names are sorted alphabetically and matched case-insensitively. diff --git a/Stampede.Http/Stampede.Http.csproj b/Stampede.Http/Stampede.Http.csproj index 4459394..7c8946e 100644 --- a/Stampede.Http/Stampede.Http.csproj +++ b/Stampede.Http/Stampede.Http.csproj @@ -2,8 +2,8 @@ net8.0;net10.0 - 2.1.0 - 2.1.0 + 2.2.0 + 2.2.0 FranRuiz98 RFC 9111 HTTP caching and request coalescing for the .NET HttpClient pipeline. Deduplicates concurrent requests, prevents cache stampedes, and adds ETag/Last-Modified revalidation, stale-while-revalidate and stale-if-error (RFC 5861), and distributed cache support — composable DelegatingHandlers that slot in alongside Polly. MIT