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