Skip to content

feat(web): Web.RateLimiting inbound rate-limiting middleware and policies [L03.01.01.12] - #924

Merged
dotnetcadet merged 9 commits into
mainfrom
feature/L03.01.01.12-rate-limiting
Jul 20, 2026
Merged

feat(web): Web.RateLimiting inbound rate-limiting middleware and policies [L03.01.01.12]#924
dotnetcadet merged 9 commits into
mainfrom
feature/L03.01.01.12-rate-limiting

Conversation

@dotnetcadet

Copy link
Copy Markdown
Contributor

Summary

Stack 5/8 of the Web/Http Batch 4b stacked series (base: feature/L03.01.01.09-https-policy, PR #923).

New feature package Assimalign.Cohesion.Web.RateLimiting — middleware, policy model, and partition-key surface over the BCL System.Threading.RateLimiting engine (pinned 10.0.0; no limiter algorithms reimplemented):

  • UseRateLimiting() with a global policy (up-front AcquireAsync, full BCL queueing honored — the flood shield) plus named policies; global and per-endpoint gates are additive (a spent permit can't be retroactively skipped; matches ASP.NET GlobalLimiter chaining, deliberately unlike RequestTimeouts' replace model — documented).
  • Per-endpoint attachment mirrors the [L03.01.01.13] Add request-timeout policies over the request-lifetime abort feature #784 decorated-context seam: sealed RateLimitingMetadata carrier read at the router's IRouteMatchFeature publication; the synchronous seam means endpoint gates use non-queueing AttemptAcquire, with rejection raised as an internal control-flow signal the middleware catches (never escapes; ordering constraint vs the [L03.01.01.19] Pipeline exception boundary, status-code pages, and 404 terminal over IResult (supersedes #776) #881 boundary documented in DESIGN — review addition).
  • Partition keys: PartitionedRateLimiter<IHttpContext> with AOT-safe selectors — ClientAddress composes with [L03.01.01.07] Add forwarded-headers middleware with a proxy trust model #778 forwarded trust via the effective client (transport peer otherwise; BCP 38 caution documented), Header, and typed delegates.
  • Rejection: 429 default (configurable) + Retry-After delta-seconds from lease metadata; OnRejected may own the response (bodyless default composes with status-code pages); committed heads abort at the protocol layer; request-abort during queue wait surfaces as the clean-drain OCE path.
  • IRateLimitingFeature exposes the decision (policy, acquired, retry-after); leases held per-request and released deterministically in the middleware finally (the concurrency-limiter lifetime). Limiters are process-lifetime (no pipeline disposal hook — documented posture + follow-up).
  • Telemetry AC re-scoped (no OTel seam reachable under hosting isolation): lightweight OnDecision observation hook; OTel composition recorded as follow-up. Resilience.RateLimiting graduation stays in epic [L01.01.17] Foundation - Resilience #318 per the issue.

Wiring (new-project checklist)

csproj (Web, Web.Routing, Http, Http.Forwarded, Http.Streaming + CohesionPackageReference System.Threading.RateLimiting) · App.props manifest + pack validated (the BCL package is deliberately NOT manifest-carried, matching the Resilience.RateLimiting precedent; the latent out-of-band-DLL delivery gap is noted as pre-existing) · both slnx · CI matrix · OVERVIEW/DESIGN + README row.

Tests

20 new (15 unit + 5 e2e; deterministic fixed-window/concurrency designs — no wall-clock fragility, no intra-connection concurrency against the sequential in-memory driver) — all passing; stack regressions green (ErrorHandling 37, Serialization 42, Compression 27, HttpsPolicy 32, Hosting 49, RequestTimeouts 31).

Follow-up candidates (not filed)

Pipeline disposal hook for builder-owned limiters · hosting-side OTel wiring for OnDecision · queued-transition observation · out-of-band package-DLL delivery in the framework runtime pack (pre-existing, also affects Resilience.RateLimiting).

Closes #783

🤖 Generated with Claude Code

dotnetcadet and others added 9 commits July 19, 2026 16:05
Commit 6688b71 renamed HttpAuthenticationFeature -> AuthenticationFeature but
left HttpContextAuthenticationExtensions (namespace Assimalign.Cohesion.Web.Authentication)
without a using for the child .Internal namespace where the concrete type lives,
breaking the Web.Authentication build and every project that references it —
including the Web.Hosting test project this task needs to verify. Add the missing using.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al via OnError hook [L03.01.01.19]

Implements #881 as a CONSUMER of the shipped #864 OnError chain (PR #893),
re-scoped from the issue's IResult design: IResult was withdrawn pre-merge, so the
Web area is middleware-first and there is no result-carrier channel. No IResult, no
Results.Problem/ExecuteResultAsync, no second handler abstraction.

Web.ErrorHandling (new pipeline verbs, homed with AddErrorHandling):
- UseErrorHandling() installs the exception-boundary middleware: catches faults
  escaping downstream, publishes IHttpExceptionFeature (caught exception + path),
  and on an unstarted response resets it and dispatches through the shipped
  IErrorHandlingFeature.Handlers chain (registration order, first-true wins) to the
  ProblemDetails-500 terminal. No-clobber: when IHttpResponseStreamingFeature
  .HasStarted reports the head is committed, it aborts the one exchange via
  IHttpContext.CancelAsync instead of half-writing (connection survives).
- Developer-detail toggle (off by default) enriches only the boundary's terminal
  fallback. OnException observation hook + SuppressDiagnosticsCallback are the
  Cohesion parity for .NET 10 SuppressDiagnosticsCallback (no Microsoft.Extensions
  logging). Handler faults propagate, never masked (shipped OnError semantics);
  an observer fault is swallowed (observation must not defeat rendering).
- UseStatusCodePages() upgrades a bodyless 4xx/5xx terminal response into
  problem+json (or a custom responder).

Web.Hosting:
- The silent Task.CompletedTask pipeline terminal now sets a bodyless 404 for an
  unhandled request (still 200, no body/content-type/location). It stays payload-
  free because COHRES002 forbids the runtime module referencing Web.ProblemDetails;
  UseStatusCodePages upgrades it. A deliberate empty 200 must be terminal (not chain
  to next) — the TLS integration test's terminal handler is adapted to match.

Tests: 22 new Web.ErrorHandling unit tests + 3 end-to-end (UseErrorHandling default,
handler-owns, UseStatusCodePages 404 upgrade) + 3 Web.Hosting terminal tests. Docs:
Web.ErrorHandling DESIGN/OVERVIEW, Web.Hosting DESIGN, Web README updated; fixed the
IHttpErrorHandler/IHttpErrorHandlingFeature naming drift in OVERVIEW.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ature naming drift

Review follow-ups on the #881 branch: implement IWebApplicationContext.ContentRootPath
on the Web.Routing TestWebApplicationContext (stale since 6688b71) and correct the
IHttpErrorHandlingFeature -> IErrorHandlingFeature name drift in the ErrorHandling
csproj description, xmldoc, and DESIGN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1.02.01.02]

Adds server-side content negotiation as a thin layer over the #864
content-serialization registry (#149), reusing the #771
`HttpContentNegotiation` q-value/precedence primitive rather than
re-implementing selection.

- `HttpContentNegotiationExtensions`: `IHttpContentSerializationFeature.TryNegotiate`
  (pure, non-throwing seam over the registered writers), `IHttpContext.TryNegotiateContentType`
  (reads the exchange's Accept), and `IHttpContext.WriteNegotiatedContentAsync<T>`
  (negotiate -> write via the registry's explicit media-type overload, or compose a 406).
- `ContentNegotiator` (internal): collects the writers' concrete media types in
  server-preference order and delegates exact RFC 9110 §12.5.1 matching to the primitive.
- Structured-suffix fallback (the call #864 deferred to #149): a bare base-type Accept
  range (application/json) is satisfied by a registered structured-suffix writer
  (application/problem+json) only when exact matching yields nothing; already-suffixed
  ranges are never widened, q=0 refusals are honored, and `application/*+json` wildcard-suffix
  ranges stay unsupported (a #771 parser gap, recorded not duplicated).
- `Vary: Accept` appended (never clobbering an existing Vary) on negotiated responses and
  the 406; no acceptable representation is a bodyless 406 outcome the #881 status-code-pages
  middleware upgrades, while a missing registry stays the existing composition fault.
- Media types only; Accept-Charset/Language and the client half are non-goals.

DESIGN.md/OVERVIEW.md updated in the same change. 15 new tests (11 unit + 4 pipeline);
Web.Serialization 42, Web.ErrorHandling 37, Web.Hosting 49 all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on [L03.01.01.08]

New resources/Web feature package housing both directions of HTTP body
compression over BCL codecs only (GZipStream/BrotliStream/ZLibStream), AOT-safe,
builder-time options, no Microsoft.Extensions.* and no hosting reference.

Response side (UseResponseCompression): negotiates gzip/brotli from Accept-Encoding
via the shared Http #771 primitives, wrapping IHttpResponse.Body with a
first-write-deferred decision stream (there is no header-commit hook). It stamps
Content-Encoding, drops the stale Content-Length so the transport re-synthesizes it,
and always appends Vary: Accept-Encoding for eligible media types (no-clobber over an
existing Accept token). Honors a size threshold without buffering whole responses,
never double-compresses already-encoded content, and hands off cleanly to streamed
responses. Off by default for HTTPS dynamic content (BREACH, CVE-2013-3587) via an
explicit EnableForHttps opt-in. Per-response opt-out via IResponseCompressionFeature.

Request side (UseRequestDecompression): transparently inflates gzip/br/deflate bodies
by decorating the exchange (IHttpRequest.Body is get-only), enforcing a decompressed-
size guard (413) against zip bombs, 415 on unsupported codings, 400 on malformed
content, and decoding multi-coding chains (Content-Encoding: gzip, br) in reverse
application order.

Wiring: App.Web framework manifest, root + Web solutions, resource-web CI matrix,
and the Web README project map. Docs: OVERVIEW.md + DESIGN.md.

Tests: 27 (in-memory factory E2E for both directions incl. round-trips, threshold,
Vary append, 413/415/400, multi-coding; unit tests for the BREACH gate and MIME
matcher). Web.ErrorHandling/Serialization/Hosting stack suites remain green.

No new Http-core primitive was needed: HttpAcceptParser.ParseAcceptEncoding +
HttpContentNegotiation.TrySelectEncoding (#771) already cover content-coding/q-value
parsing and identity;q=0 semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…port-Security header key [L03.01.01.09]

Add resources/Web/Assimalign.Cohesion.Web.HttpsPolicy, one lean feature package
pairing both HTTPS-posture concerns as extension(IWebApplicationPipelineBuilder)
verbs (no DI/config/logging; refs Web + Http only, never Web.Hosting):

- UseHttpsRedirection: insecure request -> bodyless method-preserving redirect
  (307 default, 308 configurable); Location rebuilt from the request (https
  scheme, host with its port swapped to the configured HTTPS port — 443 omitted,
  IPv6 re-bracketed; path verbatim; query reconstructed from the parsed
  collection). HttpsPort is explicit (Web.Hosting isolation forbids deriving it).
- UseHsts: RFC 6797 Strict-Transport-Security composed once at builder time
  (max-age default 365d, includeSubDomains, preload), emitted on secure responses
  only (RFC 6797 7.2), loopback (localhost/127.0.0.1/[::1]) excluded by default
  via the core HttpHostMatcher. Applied post-next (outside the #881 boundary) so
  it survives the boundary's header reset on a faulted response; skipped on a
  committed head via IHttpHeaderCollection.IsReadOnly.

Connection security is read from the transport-derived typed IHttpRequest.Scheme
(#763), not scheme-string sniffing.

Also fix HttpHeaderKey.StrictTransportSecurity value from the misspelled
"Strict-Transports-Security" to "Strict-Transport-Security" (RFC 6797), with
round-trip tests through IHttpHeaderCollection (member name unchanged).

Wiring: App.Web manifest, both slnx files, resource-web.yml matrix, README map,
OVERVIEW/DESIGN docs. Tests: 32 new (redirection + HSTS) via unit-level context
and pipeline-builder doubles; 3 new core Http header-key tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… detection

Review follow-up: Request.Scheme is the immediate hop; Web.ForwardedHeaders
deliberately never mutates it, so document the plaintext-hop redirect-loop
hazard and the IHttpForwardedFeature follow-up rather than leaving it implicit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cies [L03.01.01.12]

New resources/Web feature package adapting the BCL System.Threading.RateLimiting
engine to the Web pipeline — supplying the middleware, policy model, partition-key
surface, and rejection response, never a limiter algorithm (#783).

- UseRateLimiting composes a global limiter (applied to every request, acquired
  up-front with full queueing) plus named policies attached per-endpoint as the
  sealed RateLimitingMetadata carrier in the routing metadata bag.
- Per-endpoint gating mirrors the Web.RequestTimeouts seam: a decorated context
  reads the matched endpoint's metadata at the router's route-match publication.
  Since the router matches and dispatches in one middleware, the sync gate uses
  AttemptAcquire and raises an internal signal the middleware catches to answer
  before the handler runs. Global + endpoint are additive (both must grant a lease).
- Partitioned via PartitionedRateLimiter<IHttpContext> with AOT-safe selectors:
  RateLimitPartitionKeys.ClientAddress composes the Http.Forwarded effective client
  (trust-gated; BCP 38 caution documented), plus Header and typed selectors.
- Rejection: 429 (configurable) + Retry-After from lease metadata, OnRejected hook
  (bodyless default composes with #881 status-code pages), OnDecision observation
  hook in place of a hosting-layer OTel seam (re-scoped, follow-up recorded).
- IRateLimitingFeature exposes the decision downstream. Limiters live for the
  application lifetime; per-request leases released on completion (disposal posture
  documented).

Wiring: App.Web manifest, both slnx, resource-web.yml matrix, README project map,
OVERVIEW/DESIGN docs. System.Threading.RateLimiting is not added to the framework
manifest — it carries only Cohesion assemblies, matching the existing
Resilience.RateLimiting precedent in base App. 20 new tests (15 middleware unit +
5 e2e over WebApplicationTestFactory), all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ception boundary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from feature/L03.01.01.09-https-policy to main July 20, 2026 11:13
@dotnetcadet
dotnetcadet merged commit 4b08d74 into main Jul 20, 2026
88 checks passed
@dotnetcadet
dotnetcadet deleted the feature/L03.01.01.12-rate-limiting branch July 20, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[L03.01.01.12] Build Web.RateLimiting: inbound rate-limiting middleware and policies

1 participant