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
10 changes: 10 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
###############################################################################
* text=auto

###############################################################################
# Files consumed by Linux containers and CI must keep LF regardless of the
# platform they were edited on: a CRLF shell script fails with "\r: command
# not found", and a CRLF Dockerfile breaks its ENTRYPOINT.
###############################################################################
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf

###############################################################################
# Set default behavior for command prompt diff.
#
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
- name: Restore
run: dotnet restore Stampede.Http.slnx

# The solution includes samples/, so a sample that stops compiling fails CI
# instead of rotting unnoticed.
- name: Build
run: dotnet build Stampede.Http.slnx -c Release --no-restore

Expand All @@ -32,3 +34,25 @@ jobs:

- name: Demo smoke test
run: dotnet run -c Release --no-build --project Stampede.Http.Demo

# Brings the whole sample stack up (origin + Redis + three clients + Prometheus +
# Grafana + Jaeger) and asserts the behaviour the sample README claims, using the
# origin's own request counters as the source of truth.
samples-smoke:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

# KEEP_STACK leaves the containers up so the log-dump step below has something
# to read; the runner is thrown away either way.
- name: Run the sample stack smoke test
working-directory: samples
env:
KEEP_STACK: "1"
run: bash scripts/smoke-test.sh

- name: Dump container logs on failure
if: failure()
working-directory: samples
run: docker compose logs --tail 200
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,19 @@ BenchmarkDotNet v0.15.2 · .NET 10 · Windows 11 · i7-12650H.

---

## Runnable sample

[`samples/`](samples/) contains a full deployment — origin API, Redis, Polly, Prometheus, Grafana, Jaeger and a k6 load profile — plus a **control group**: a third instance of the same app with the Stampede.Http handlers removed, so the difference is measured rather than asserted.

```bash
cd samples && docker compose up --build -d
docker compose logs -f client-a
```

It narrates itself: a ten-caller stampede, then twelve feature scenarios each verified against the origin's own request counters, then a steady-state loop feeding the dashboards. See the [sample README](samples/README.md).

---

## Running the tests

```bash
Expand Down
4 changes: 4 additions & 0 deletions Stampede.Http.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
<Project Path="Stampede.Http.Demo/Stampede.Http.Demo.csproj" />
<Project Path="Stampede.Http.Tests/Stampede.Http.Tests.csproj" />
<Project Path="Stampede.Http/Stampede.Http.csproj" />
<Folder Name="/samples/">
<Project Path="samples/Stampede.Http.Sample.Api/Stampede.Http.Sample.Api.csproj" />
<Project Path="samples/Stampede.Http.Sample.Client/Stampede.Http.Sample.Client.csproj" />
</Folder>
</Solution>
301 changes: 242 additions & 59 deletions samples/README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions samples/Stampede.Http.Sample.Api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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
# curl is here only so docker compose can run a real HTTP healthcheck against /health.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
Expand Down
140 changes: 140 additions & 0 deletions samples/Stampede.Http.Sample.Api/Endpoints/CoreEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
namespace Stampede.Http.Sample.Api.Endpoints;

/// <summary>
/// The core RFC 9111 / RFC 5861 scenarios: freshness, conditional revalidation,
/// unsafe-method invalidation, stale-while-revalidate, stale-if-error and the
/// slow endpoint used for the stampede.
/// </summary>
public static class CoreEndpoints
{
/// <summary>Maps the core caching endpoints.</summary>
/// <param name="app">The route builder to map onto.</param>
/// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapCoreEndpoints(this IEndpointRouteBuilder app)
{
// GET /catalog — max-age + ETag: fresh hits, then conditional revalidation
// (a 304 costs no body). stale-if-error keeps the entry usable during outages.
app.MapGet("/catalog", async (HttpRequest req, HttpResponse res, OriginState state) =>
{
state.Count("GET /catalog");
await Task.Delay(300);

string etag = $"\"catalog-v{state.CatalogVersion}\"";
res.Headers.CacheControl = "public, max-age=10, stale-if-error=60";
res.Headers.ETag = etag;

if (req.Headers.IfNoneMatch.ToString().Contains(etag, StringComparison.Ordinal))
{
state.Count("GET /catalog -> 304");
return Results.StatusCode(StatusCodes.Status304NotModified);
}

return Results.Json(new
{
version = state.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 that
// invalidation is shared by every client instance.
app.MapPost("/catalog", (OriginState state) =>
{
state.Count("POST /catalog");
state.BumpCatalogVersion();
return Results.NoContent();
});

// GET /feed — stale-while-revalidate: expiries are refreshed in the background,
// so callers never wait for the origin after the first fetch.
app.MapGet("/feed", async (HttpResponse res, OriginState state) =>
{
state.Count("GET /feed");
await Task.Delay(500);
res.Headers.CacheControl = "max-age=5, stale-while-revalidate=30";
return Results.Json(new
{
generation = state.NextFeedGeneration(),
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, OriginState state) =>
{
state.Count("GET /flaky");
if (OriginState.FlakyIsDown())
{
state.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, OriginState state) =>
{
state.Count("GET /slow");
await Task.Delay(2000);
res.Headers.CacheControl = "max-age=30";
return Results.Json(new { report = "quarterly", servedAt = DateTimeOffset.UtcNow });
});

// GET /ledger — max-age + must-revalidate + ETag: once stale the entry may NOT
// be served without checking the origin, so no stale window applies even under
// failure. Contrast with /flaky.
app.MapGet("/ledger", async (HttpRequest req, HttpResponse res, OriginState state) =>
{
state.Count("GET /ledger");
await Task.Delay(200);

const string Etag = "\"ledger-2026-07\"";
res.Headers.CacheControl = "max-age=10, must-revalidate";
res.Headers.ETag = Etag;

if (req.Headers.IfNoneMatch.ToString().Contains(Etag, StringComparison.Ordinal))
{
state.Count("GET /ledger -> 304");
return Results.StatusCode(StatusCodes.Status304NotModified);
}

return Results.Json(new { balance = 42_150.75m, closed = true });
});

// GET /docs/{id} — validator is Last-Modified rather than ETag, so expiry
// triggers an If-Modified-Since revalidation.
app.MapGet("/docs/{id}", (string id, HttpRequest req, HttpResponse res, OriginState state) =>
{
state.Count("GET /docs/{id}");

// Stable per-document timestamp so If-Modified-Since can match.
DateTimeOffset lastModified = new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero)
.AddMinutes(id.GetHashCode(StringComparison.Ordinal) % 60);

res.Headers.CacheControl = "max-age=5";
res.Headers.LastModified = lastModified.ToString("R", System.Globalization.CultureInfo.InvariantCulture);

if (DateTimeOffset.TryParse(
req.Headers.IfModifiedSince,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out DateTimeOffset since)
&& lastModified <= since)
{
state.Count("GET /docs/{id} -> 304");
return Results.StatusCode(StatusCodes.Status304NotModified);
}

return Results.Json(new { id, title = $"Document {id}", lastModified });
});

return app;
}
}
45 changes: 45 additions & 0 deletions samples/Stampede.Http.Sample.Api/Endpoints/DiagnosticsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Stampede.Http.Sample.Api.Endpoints;

/// <summary>
/// Endpoints the origin uses to describe itself: liveness, live counters and a
/// counter reset used by the automated smoke test.
/// </summary>
public static class DiagnosticsEndpoints
{
/// <summary>Maps the diagnostics endpoints.</summary>
/// <param name="app">The route builder to map onto.</param>
/// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
{
// GET /health — compose healthcheck target. Never cached.
app.MapGet("/health", (HttpResponse res) =>
{
res.Headers.CacheControl = "no-store";
return Results.Ok(new { status = "healthy" });
});

// GET /stats — no-store: live origin-side counters. This is how you see how
// many requests actually reached the origin across ALL client instances.
app.MapGet("/stats", (HttpResponse res, OriginState state) =>
{
res.Headers.CacheControl = "no-store";
return Results.Json(new
{
flakyIsDown = OriginState.FlakyIsDown(),
catalogVersion = state.CatalogVersion,
counters = state.Snapshot(),
});
});

// POST /stats/reset — clears the counters so a measurement window can start
// from zero. Used by scripts/smoke-test.sh.
app.MapPost("/stats/reset", (HttpResponse res, OriginState state) =>
{
res.Headers.CacheControl = "no-store";
state.ResetCounters();
return Results.NoContent();
});

return app;
}
}
99 changes: 99 additions & 0 deletions samples/Stampede.Http.Sample.Api/Endpoints/VariantEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Text;

namespace Stampede.Http.Sample.Api.Endpoints;

/// <summary>
/// Endpoints that exercise content negotiation (<c>Vary</c>), multi-tenancy,
/// <c>immutable</c>, the body-size ceiling and query-parameter normalisation.
/// </summary>
public static class VariantEndpoints
{
private static readonly string[] BulkPayload = Enumerable.Range(0, 20_000)
.Select(i => $"row-{i:D6}-{new string('x', 80)}")
.ToArray();

/// <summary>Maps the variant / policy-showcase endpoints.</summary>
/// <param name="app">The route builder to map onto.</param>
/// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapVariantEndpoints(this IEndpointRouteBuilder app)
{
// GET /greetings — Vary: Accept-Language. One URL, one cached variant per
// language (RFC 9111 §4.1). Before v2.2.0 these overwrote each other.
app.MapGet("/greetings", async (HttpRequest req, HttpResponse res, OriginState state) =>
{
state.Count("GET /greetings");
await Task.Delay(200);

string lang = req.Headers.AcceptLanguage.ToString();
string greeting = lang.StartsWith("es", StringComparison.OrdinalIgnoreCase) ? "¡Hola!"
: lang.StartsWith("fr", StringComparison.OrdinalIgnoreCase) ? "Bonjour !"
: "Hello!";

res.Headers.CacheControl = "max-age=30";
res.Headers.Vary = "Accept-Language";
return Results.Json(new { greeting, language = lang, servedAt = DateTimeOffset.UtcNow });
});

// GET /tenants/data — the multi-tenant case: one URL, tenant chosen by header.
// Vary: X-Tenant-Id gives each tenant its own cache entry, and the client adds
// X-Tenant-Id to CoalesceKeyHeaders so concurrent bursts are deduplicated
// per tenant instead of collapsing two tenants into one response.
app.MapGet("/tenants/data", async (HttpRequest req, HttpResponse res, OriginState state) =>
{
string tenant = req.Headers["X-Tenant-Id"].ToString();
tenant = string.IsNullOrWhiteSpace(tenant) ? "public" : tenant;

state.Count($"GET /tenants/data [{tenant}]");
await Task.Delay(1000);

res.Headers.CacheControl = "max-age=20";
res.Headers.Vary = "X-Tenant-Id";
return Results.Json(new { tenant, seats = tenant.Length * 10, servedAt = DateTimeOffset.UtcNow });
});

// GET /assets/{id} — Cache-Control: immutable (RFC 8246). A fresh immutable
// entry skips revalidation even when the caller asks for one, which is exactly
// what you want for content-addressed assets.
app.MapGet("/assets/{id}", (string id, HttpResponse res, OriginState state) =>
{
state.Count("GET /assets/{id}");
res.Headers.CacheControl = "public, max-age=31536000, immutable";
res.Headers.ETag = $"\"asset-{id}\"";
return Results.Json(new { id, bytes = 4096, servedAt = DateTimeOffset.UtcNow });
});

// GET /bulk — a ~1.8 MB body with a perfectly good max-age. It still never gets
// cached: it exceeds CacheOptions.MaxBodySizeBytes (1 MB by default), so the
// cache declines to store it rather than blowing up memory.
app.MapGet("/bulk", (HttpResponse res, OriginState state) =>
{
state.Count("GET /bulk");
res.Headers.CacheControl = "max-age=300";

StringBuilder sb = new(BulkPayload.Length * 90);
foreach (string row in BulkPayload)
{
_ = sb.AppendLine(row);
}

return Results.Text(sb.ToString(), "text/plain");
});

// GET /search — the same logical query can arrive with its parameters in any
// order. With CacheOptions.NormalizeQueryParameters the client folds
// ?a=1&b=2 and ?b=2&a=1 onto one entry; without it they are two misses.
app.MapGet("/search", (HttpRequest req, HttpResponse res, OriginState state) =>
{
state.Count("GET /search");
res.Headers.CacheControl = "max-age=60";

var query = req.Query
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.ToDictionary(kv => kv.Key, kv => kv.Value.ToString(), StringComparer.Ordinal);

return Results.Json(new { query, servedAt = DateTimeOffset.UtcNow });
});

return app;
}
}
Loading
Loading