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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/bin/
**/obj/
**/TestResults/
**/BenchmarkDotNet.Artifacts/
.git/
.github/
35 changes: 13 additions & 22 deletions Stampede.Http/Caching/CachingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

/// <summary>
Expand Down
87 changes: 87 additions & 0 deletions samples/README.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions samples/Stampede.Http.Sample.Api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
115 changes: 115 additions & 0 deletions samples/Stampede.Http.Sample.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -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<string, int>();
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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

</Project>
11 changes: 11 additions & 0 deletions samples/Stampede.Http.Sample.Client/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading