Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ──────────────────
Expand Down
213 changes: 213 additions & 0 deletions Stampede.Http.Tests/Caching/VaryVariantCachingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that the middleware stores and serves multiple representations of the same URL keyed on their
/// <c>Vary</c> 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.
/// </summary>
public sealed class VaryVariantCachingTests
{
private readonly DefaultCacheKeyBuilder _keyBuilder = new();

private static CachingMiddleware BuildPipeline(
ICacheStore store,
Func<HttpRequestMessage, HttpResponseMessage> 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;
}

/// <summary>Origin that varies on Accept-Language and echoes the negotiated language in the body.</summary>
private static HttpResponseMessage VaryingByLanguage(HttpRequestMessage request)
{
string lang = request.Headers.TryGetValues("Accept-Language", out IEnumerable<string>? 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<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
Loading
Loading