diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c2f92c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +**/TestResults/ +**/BenchmarkDotNet.Artifacts/ +.git/ +.github/ diff --git a/Stampede.Http/Caching/CachingMiddleware.cs b/Stampede.Http/Caching/CachingMiddleware.cs index 90bd401..7965c9e 100644 --- a/Stampede.Http/Caching/CachingMiddleware.cs +++ b/Stampede.Http/Caching/CachingMiddleware.cs @@ -93,29 +93,20 @@ private static bool IsResponseCacheable(HttpResponseMessage response) return false; } - switch (response.StatusCode) - { - case HttpStatusCode.OK: - // 200 is always eligible (guards above already applied) - return true; - - case HttpStatusCode.MovedPermanently: - // 301: heuristically cacheable — max-age/Expires/DefaultTtl all valid (RFC 9111 §3.2) - return true; - - case HttpStatusCode.NotFound: - case HttpStatusCode.MethodNotAllowed: - case HttpStatusCode.Gone: - case HttpStatusCode.RequestUriTooLong: + return response.StatusCode switch + { + HttpStatusCode.OK => true, // 200 is always eligible (guards above already applied) + HttpStatusCode.MovedPermanently => true, // 301: heuristically cacheable + HttpStatusCode.NotFound or + HttpStatusCode.MethodNotAllowed or + HttpStatusCode.Gone or + HttpStatusCode.RequestUriTooLong => // 404/405/410/414: only cache when an explicit freshness directive is present - // (no heuristic fallback — caching indefinite errors is dangerous) - return cacheControl?.MaxAge is not null - || cacheControl?.SharedMaxAge is not null - || response.Content?.Headers.Expires is not null; - - default: - return false; - } + cacheControl?.MaxAge is not null + || cacheControl?.SharedMaxAge is not null + || response.Content?.Headers.Expires is not null, + _ => false + }; } /// diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..4165a59 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,87 @@ +# Stampede.Http — real-world sample + +A complete, runnable deployment showing **Stampede.Http + Redis + Polly** working together over a real network: + +``` +┌─────────────┐ ┌─────────────┐ +│ client ×2 │──►│ Sample API │ client pipeline: +│ (replicas) │ │ (Kestrel) │ CachingMiddleware ← Redis-backed, shared +└──────┬──────┘ └─────────────┘ └─ CoalescingHandler ← per process + │ └─ Polly retry + timeout + ▼ └─ HttpClientHandler +┌─────────────┐ +│ Redis │ ← one shared HTTP cache for every client instance +└─────────────┘ +``` + +The API declares caching policy purely through standard headers (`Cache-Control`, `ETag`, `Vary`) — there is **zero Stampede.Http code on the server**. The client configures the whole pipeline in one statement: + +```csharp +services.AddHttpClient("api", c => c.BaseAddress = new Uri(apiBase)) + .AddStampedeHttp( + configureCaching: o => o.DefaultTtl = TimeSpan.FromSeconds(5), + configureCoalescing: o => o.CoalescingTimeout = TimeSpan.FromSeconds(10)) + .UseDistributedCacheStore() // Redis via IDistributedCache + .AddResilienceHandler("sample-resilience", b => // Polly: retries + timeout + { + b.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 2, BackoffType = DelayBackoffType.Exponential }); + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); +``` + +## Run it + +```bash +docker compose up --build +``` + +Requires Docker. Watch the two `client` replicas' logs. + +### Without Docker (except Redis) + +```bash +docker run -d -p 6379:6379 redis:7-alpine +dotnet run --project Stampede.Http.Sample.Api # listens on http://localhost:5080 +dotnet run --project Stampede.Http.Sample.Client +``` + +(Set `ASPNETCORE_URLS=http://localhost:5080` for the API if your default differs.) + +## What to watch + +| Moment | What it demonstrates | +|---|---| +| **Phase 1**: 10 concurrent `GET /slow` (2 s origin latency) finish in ~2 s total | Request coalescing — each instance's burst collapses into one origin call | +| The *second* replica's Phase 1 is instant | The Redis cache is **shared**: instance B hits the entry instance A stored | +| `GET /catalog` shows `Age: Ns` and ~0 ms | Fresh hits served from Redis, no network to the origin | +| After 10 s, one slow `/catalog` request, then fast again | Expiry → conditional revalidation (`If-None-Match`, 304) | +| `POST /catalog` line, then next `GET /catalog` refetches with a new version | Unsafe-method invalidation (RFC 9111 §4.4) — shared through Redis, both replicas see it | +| `/flaky` keeps returning **200** while the origin is in its failure window | Polly retries the blips; when the outage persists, `stale-if-error` serves the last good response (look for `[likely STALE — origin shielded]`) | +| `/feed` never blocks after the first fetch | `stale-while-revalidate` refreshes in the background | +| Periodic `stampede_http.*` metrics block | The same counters you would export via OpenTelemetry | + +### The outage drill + +```bash +docker compose stop api +``` + +The clients keep receiving **200 OK** for `/catalog` and `/flaky` (stale-if-error window) instead of connection errors. Then: + +```bash +docker compose start api +``` + +and watch them transparently return to fresh responses. + +### Origin's point of view + +```bash +curl http://localhost:5080/stats +``` + +`no-store` live counters: how many requests actually reached the origin — across *all* client instances. Compare it with how many requests the clients have issued. + +## One nuance worth knowing + +The **cache** (Redis) is shared across instances, but **coalescing is per process**: if both replicas miss the same key at the same instant, each makes its own origin call (2 total, not 1 — still not 20). Cross-instance request deduplication would require a distributed lock, which is out of scope for an HTTP client library. diff --git a/samples/Stampede.Http.Sample.Api/Dockerfile b/samples/Stampede.Http.Sample.Api/Dockerfile new file mode 100644 index 0000000..031f819 --- /dev/null +++ b/samples/Stampede.Http.Sample.Api/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY samples/Stampede.Http.Sample.Api/ samples/Stampede.Http.Sample.Api/ +RUN dotnet publish samples/Stampede.Http.Sample.Api -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Stampede.Http.Sample.Api.dll"] diff --git a/samples/Stampede.Http.Sample.Api/Program.cs b/samples/Stampede.Http.Sample.Api/Program.cs new file mode 100644 index 0000000..c8183ef --- /dev/null +++ b/samples/Stampede.Http.Sample.Api/Program.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; + +// --------------------------------------------------------------------------- +// Sample origin API — every endpoint drives Stampede.Http purely through +// standard HTTP caching headers. There is no Stampede.Http code here: the +// origin declares policy, the client-side cache obeys it. +// --------------------------------------------------------------------------- + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var counters = new ConcurrentDictionary(); +int catalogVersion = 1; +int feedGeneration = 0; + +void Count(string key) => counters.AddOrUpdate(key, 1, (_, v) => v + 1); + +// /flaky is "down" for the first 20 seconds of every 60-second window. +bool FlakyIsDown() => Environment.TickCount64 / 1000 % 60 < 20; + +// GET /catalog — max-age + ETag: demonstrates fresh hits and conditional +// revalidation (304 costs no body). stale-if-error keeps the entry usable +// during outages. +app.MapGet("/catalog", async (HttpRequest req, HttpResponse res) => +{ + Count("GET /catalog"); + await Task.Delay(300); + + string etag = $"\"catalog-v{catalogVersion}\""; + res.Headers.CacheControl = "public, max-age=10, stale-if-error=60"; + res.Headers.ETag = etag; + + if (req.Headers.IfNoneMatch.ToString().Contains(etag)) + { + Count("GET /catalog -> 304"); + return Results.StatusCode(StatusCodes.Status304NotModified); + } + + return Results.Json(new { version = catalogVersion, items = 120, servedAt = DateTimeOffset.UtcNow }); +}); + +// POST /catalog — unsafe method: a 2xx response makes Stampede.Http evict the +// cached GET /catalog entry (RFC 9111 §4.4). With the Redis store this +// invalidation is shared by every client instance. +app.MapPost("/catalog", () => +{ + Count("POST /catalog"); + Interlocked.Increment(ref catalogVersion); + return Results.NoContent(); +}); + +// GET /feed — stale-while-revalidate: expiries are refreshed in the +// background; callers never wait for the origin after the first fetch. +app.MapGet("/feed", async (HttpResponse res) => +{ + Count("GET /feed"); + await Task.Delay(500); + int gen = Interlocked.Increment(ref feedGeneration); + res.Headers.CacheControl = "max-age=5, stale-while-revalidate=30"; + return Results.Json(new { generation = gen, servedAt = DateTimeOffset.UtcNow }); +}); + +// GET /flaky — fails in bursts. Polly retries transient blips; when the +// outage outlasts the retries, stale-if-error shields the caller with the +// last good 200. +app.MapGet("/flaky", async (HttpResponse res) => +{ + Count("GET /flaky"); + if (FlakyIsDown()) + { + Count("GET /flaky -> 503"); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + await Task.Delay(100); + res.Headers.CacheControl = "max-age=5, stale-if-error=120"; + return Results.Json(new { status = "healthy", servedAt = DateTimeOffset.UtcNow }); +}); + +// GET /slow — 2 s of origin latency: the stampede showcase. N concurrent +// cold-cache callers should produce a single origin call per client process. +app.MapGet("/slow", async (HttpResponse res) => +{ + Count("GET /slow"); + await Task.Delay(2000); + res.Headers.CacheControl = "max-age=30"; + return Results.Json(new { report = "quarterly", servedAt = DateTimeOffset.UtcNow }); +}); + +// GET /greetings — Vary: Accept-Language: one URL, one cached variant per +// language. +app.MapGet("/greetings", (HttpRequest req, HttpResponse res) => +{ + Count("GET /greetings"); + string lang = req.Headers.AcceptLanguage.ToString(); + string greeting = lang.StartsWith("es", StringComparison.OrdinalIgnoreCase) ? "¡Hola!" : "Hello!"; + res.Headers.CacheControl = "max-age=30"; + res.Headers.Vary = "Accept-Language"; + return Results.Json(new { greeting, language = lang }); +}); + +// GET /stats — no-store: live origin-side counters, never cached. This is +// how the client (and you) can see how many requests actually reached the +// origin across ALL client instances. +app.MapGet("/stats", (HttpResponse res) => +{ + res.Headers.CacheControl = "no-store"; + return Results.Json(new + { + flakyIsDown = FlakyIsDown(), + counters = counters.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value), + }); +}); + +app.Run(); diff --git a/samples/Stampede.Http.Sample.Api/Stampede.Http.Sample.Api.csproj b/samples/Stampede.Http.Sample.Api/Stampede.Http.Sample.Api.csproj new file mode 100644 index 0000000..a3a34b6 --- /dev/null +++ b/samples/Stampede.Http.Sample.Api/Stampede.Http.Sample.Api.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/samples/Stampede.Http.Sample.Client/Dockerfile b/samples/Stampede.Http.Sample.Client/Dockerfile new file mode 100644 index 0000000..6d2b946 --- /dev/null +++ b/samples/Stampede.Http.Sample.Client/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY README.md README.md +COPY Stampede.Http/ Stampede.Http/ +COPY samples/Stampede.Http.Sample.Client/ samples/Stampede.Http.Sample.Client/ +RUN dotnet publish samples/Stampede.Http.Sample.Client -c Release -o /app + +FROM mcr.microsoft.com/dotnet/runtime:10.0 +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "Stampede.Http.Sample.Client.dll"] diff --git a/samples/Stampede.Http.Sample.Client/Program.cs b/samples/Stampede.Http.Sample.Client/Program.cs new file mode 100644 index 0000000..a9240e7 --- /dev/null +++ b/samples/Stampede.Http.Sample.Client/Program.cs @@ -0,0 +1,185 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Polly; +using Stampede.Http.Extensions; +using Stampede.Http.Metrics; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +// --------------------------------------------------------------------------- +// Sample client — a realistic Stampede.Http pipeline: +// +// CachingMiddleware (RFC 9111, entries shared across instances via Redis) +// └─ CoalescingHandler (per-process request deduplication) +// └─ Polly (retry with exponential backoff + per-attempt timeout) +// └─ SocketsHttpHandler → the sample API +// +// Run two replicas (docker compose does) and watch the Redis-backed cache be +// shared while coalescing stays per-process. +// --------------------------------------------------------------------------- + +string instance = Environment.GetEnvironmentVariable("INSTANCE") ?? Environment.MachineName; +string apiBase = Environment.GetEnvironmentVariable("API__BASEURL") ?? "http://localhost:5080"; +string redisConn = Environment.GetEnvironmentVariable("REDIS__CONNECTION") ?? "localhost:6379"; + +Log($"starting — api: {apiBase} redis: {redisConn}", ConsoleColor.Cyan); + +// -- In-process metrics: aggregate every stampede_http.* instrument ---------- +var metrics = new Dictionary(StringComparer.Ordinal); +using var listener = new MeterListener(); +listener.InstrumentPublished = (instrument, l) => +{ + if (instrument.Meter.Name == StampedeHttpMetrics.MeterName) + l.EnableMeasurementEvents(instrument); +}; +listener.SetMeasurementEventCallback((instrument, measurement, _, _) => +{ + lock (metrics) + { + metrics.TryGetValue(instrument.Name, out long prev); + metrics[instrument.Name] = prev + measurement; + } +}); +listener.Start(); + +// -- The pipeline ------------------------------------------------------------ +var services = new ServiceCollection(); + +services.AddStackExchangeRedisCache(o => o.Configuration = redisConn); + +services.AddHttpClient("api", c => c.BaseAddress = new Uri(apiBase)) + .AddStampedeHttp( + configureCaching: o => o.DefaultTtl = TimeSpan.FromSeconds(5), + configureCoalescing: o => o.CoalescingTimeout = TimeSpan.FromSeconds(10)) + .UseDistributedCacheStore() + .AddResilienceHandler("sample-resilience", b => + { + // Polly sits BELOW the coalescer: a retry storm is coalesced too. + b.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = TimeSpan.FromMilliseconds(200), + BackoffType = DelayBackoffType.Exponential, + }); + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + +await using var provider = services.BuildServiceProvider(); +var client = provider.GetRequiredService().CreateClient("api"); + +// -- Wait for the API -------------------------------------------------------- +for (int attempt = 1; ; attempt++) +{ + try + { + using var ping = await client.GetAsync("/stats"); + if (ping.IsSuccessStatusCode) break; + } + catch when (attempt < 30) + { + } + + if (attempt >= 30) + { + Log("API not reachable after 30 attempts — giving up.", ConsoleColor.Red); + return 1; + } + + await Task.Delay(1000); +} + +Log("API is up.", ConsoleColor.Cyan); + +// -- Phase 1: the stampede --------------------------------------------------- +Log("PHASE 1 — stampede: 10 concurrent GET /slow (origin takes ~2 s each)", ConsoleColor.Yellow); +var sw = Stopwatch.StartNew(); +var burst = Enumerable.Range(0, 10).Select(_ => client.GetAsync("/slow")).ToArray(); +var burstResponses = await Task.WhenAll(burst); +sw.Stop(); +Log($" 10 callers finished in {sw.ElapsedMilliseconds} ms — " + + $"{burstResponses.Count(r => r.IsSuccessStatusCode)}/10 OK. " + + "Coalescing collapsed this instance's burst into (at most) one origin call.", ConsoleColor.Green); + +// -- Phase 2: steady-state loop ---------------------------------------------- +Log("PHASE 2 — steady state: /catalog + /feed + /flaky every 2 s. " + + "Try `docker compose stop api` and watch stale-if-error shield the 200s.", ConsoleColor.Yellow); + +int iteration = 0; +while (true) +{ + iteration++; + + await Probe("/catalog"); + await Probe("/feed"); + await Probe("/flaky"); + + // Every 10th iteration: mutate the catalog. The 2xx POST makes + // CachingMiddleware evict the shared /catalog entry (RFC 9111 §4.4), + // so the next GET — from ANY instance — refetches and sees a new ETag. + if (iteration % 10 == 0) + { + try + { + using var post = await client.PostAsync("/catalog", content: null); + Log($" POST /catalog -> {(int)post.StatusCode} — cached entry invalidated for every instance", ConsoleColor.Magenta); + } + catch (Exception ex) + { + Log($" POST /catalog failed: {ex.GetBaseException().Message}", ConsoleColor.Red); + } + } + + if (iteration % 8 == 0) + { + PrintMetrics(); + } + + await Task.Delay(2000); +} + +async Task Probe(string path) +{ + var probeSw = Stopwatch.StartNew(); + try + { + using var response = await client!.GetAsync(path); + probeSw.Stop(); + + TimeSpan? age = response.Headers.Age; + string ageText = age is null ? "fresh from origin" : $"Age: {age.Value.TotalSeconds:F0}s"; + string body = await response.Content.ReadAsStringAsync(); + if (body.Length > 60) body = body[..60] + "…"; + + var color = response.IsSuccessStatusCode + ? (age is { TotalSeconds: > 10 } ? ConsoleColor.DarkYellow : ConsoleColor.Green) + : ConsoleColor.Red; + string staleHint = age is { TotalSeconds: > 10 } ? " [served beyond max-age: stale window or revalidated entry]" : string.Empty; + + Log($" GET {path,-9} -> {(int)response.StatusCode} {probeSw.ElapsedMilliseconds,5} ms ({ageText}){staleHint} {body}", color); + } + catch (Exception ex) + { + probeSw.Stop(); + Log($" GET {path,-9} -> EXCEPTION after {probeSw.ElapsedMilliseconds} ms: {ex.GetBaseException().Message}", ConsoleColor.Red); + } +} + +void PrintMetrics() +{ + lock (metrics) + { + if (metrics.Count == 0) return; + Log(" ── stampede_http metrics (this instance) ─────────────────", ConsoleColor.Cyan); + foreach (var (name, value) in metrics.OrderBy(kv => kv.Key)) + { + Log($" {name,-50} {value,6}", ConsoleColor.Cyan); + } + } +} + +void Log(string message, ConsoleColor color) +{ + Console.ForegroundColor = color; + Console.WriteLine($"[{instance}] {message}"); + Console.ResetColor(); +} diff --git a/samples/Stampede.Http.Sample.Client/Stampede.Http.Sample.Client.csproj b/samples/Stampede.Http.Sample.Client/Stampede.Http.Sample.Client.csproj new file mode 100644 index 0000000..07f7847 --- /dev/null +++ b/samples/Stampede.Http.Sample.Client/Stampede.Http.Sample.Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/samples/docker-compose.yml b/samples/docker-compose.yml new file mode 100644 index 0000000..44f0a73 --- /dev/null +++ b/samples/docker-compose.yml @@ -0,0 +1,27 @@ +name: stampede-http-sample + +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + api: + build: + context: .. + dockerfile: samples/Stampede.Http.Sample.Api/Dockerfile + ports: + - "5080:8080" + + client: + build: + context: .. + dockerfile: samples/Stampede.Http.Sample.Client/Dockerfile + environment: + - API__BASEURL=http://api:8080 + - REDIS__CONNECTION=redis:6379 + depends_on: + - api + - redis + deploy: + replicas: 2