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