Skip to content

fix(http): emit api-versioning headers on non-2xx responses#2661

Open
outofrange-consulting wants to merge 3 commits intoJasperFx:mainfrom
outofrange-consulting:feature/http-version-headers-on-errors
Open

fix(http): emit api-versioning headers on non-2xx responses#2661
outofrange-consulting wants to merge 3 commits intoJasperFx:mainfrom
outofrange-consulting:feature/http-version-headers-on-errors

Conversation

@outofrange-consulting
Copy link
Copy Markdown

Follow-up to #2633.

Summary

ApiVersionHeaderWriter now registers headers via Response.OnStarting from the first frame of every relevant chain — versioning headers (Deprecation, Sunset, Link, api-supported-versions) are emitted on all framework-produced responses regardless of status code (2xx, IResult short-circuits, validation ProblemDetails, middleware-IResult exits).

Previously emitted as a Postprocessor, which Wolverine codegen skips when MaybeEndWithResultFrame / MaybeEndWithProblemDetailsFrame returns out of the generated handler — leaving 4xx error paths without headers.

Implementation

  • New ApiVersionHeaderFinalizationPolicy registered in MapWolverineEndpoints after configure(). Runs last, positions the writer call at chain.Middleware[0] (outranks FluentValidation / DataAnnotations / RequestId / TenantId frames that also insert at index 0). Idempotent: skips when writer is already at index 0; Debug.Assert invariant under DEBUG.
  • ApiVersionHeaderWriter.WriteAsync is the chain frame; uses static lambda + state argument to avoid per-request closure allocation. Re-fetches ApiVersionEndpointHeaderState from the matched endpoint inside the OnStarting callback because the endpoint can be re-routed by middleware between the chain frame and header-flush time. Resolves itself via RequestServices.GetRequiredService<ApiVersionHeaderWriter>() — fail-fast if DI is misconfigured.
  • ApiVersionHeaderWriter.WriteVersioningHeadersTo(HttpContext, ApiVersionEndpointHeaderState) is exposed as public ([EditorBrowsable(Advanced)]) so the documented 5xx-middleware snippet can call the same logic without copy-pasting RFC-9745 emission.
  • ChainsRequiringHeaderEmission (IReadOnlySet<HttpChain>) gives the finalization policy O(1) lookup.

Out of scope

Responses produced by the global ASP.NET Core exception handler bypass the chain pipeline entirely. Users wanting headers on 5xx wire them via separate middleware. Documented in versioning.md with a snippet that calls WriteVersioningHeadersTo.

Test plan

  • api_versioning_error_path_header_tests — 5 exit-path tests:
    • 404 IResult short-circuit
    • 400 validation ProblemDetails
    • 401 middleware short-circuit
    • 200 success
    • 5xx via global UseExceptionHandler — headers ABSENT (regression test pinning the documented out-of-scope, asserts response body marker "global-exception-handler")
  • ApiVersionHeaderWriterTests — added missing-writer-in-DI fail-fast test
  • ApiVersioningPolicyHeaderWiringTests — finalization idempotency + DEBUG-only finalization_assert_fires_when_writer_was_displaced
  • All ApiVersioning-suite tests green: 80/80
  • Full Wolverine.Http.Tests suite green: 714/714

Sample app

Four split fixtures in WolverineWebApi/ApiVersioning/:

  • OrdersV1NotFoundEndpoint.cs
  • OrdersV1CreateEndpoint.cs
  • OrdersV1RestrictedEndpoint.cs
  • OrdersV1ThrowsEndpoint.cs (intentional throw, scoped under UseWhen(.../v1/orders/throws, UseExceptionHandler))

Geoffrey MARC added 3 commits May 1, 2026 21:41
ApiVersionHeaderWriter now registers headers via Response.OnStarting
from the very first frame of every relevant chain, so versioning
headers (Deprecation, Sunset, Link, api-supported-versions) are
emitted on all framework-produced responses regardless of status code:
2xx, IResult-returning short-circuits, validation ProblemDetails, and
middleware-IResult exits.

Previously the writer ran as a Postprocessor, which Wolverine codegen
skips when MaybeEndWithResultFrame or MaybeEndWithProblemDetailsFrame
returns out of the generated handler — leaving 4xx error paths
without the headers.

A new ApiVersionHeaderFinalizationPolicy is registered in
MapWolverineEndpoints after configure(); it runs last and re-positions
the writer call to chain.Middleware index 0, outranking
FluentValidation / DataAnnotations / RequestId / TenantId frames that
also insert at index 0.

Out of scope: responses produced by the global ASP.NET Core exception
handler bypass the chain pipeline; users wanting headers on 5xx wire
them via separate middleware (documented in versioning.md).

Tests: 4 new integration tests in api_versioning_error_path_header_tests
exercise the four exit paths (404 IResult, 400 validation, 401
middleware short-circuit, success). Sample endpoints added in
WolverineWebApi/ApiVersioning/OrdersV1ErrorPathsEndpoint.cs.
- ApiVersionHeaderFinalizationPolicy: switch to IReadOnlySet for O(1)
  Contains, drop the unreachable re-position branch in favor of a
  Debug.Assert idempotency invariant.
- ApiVersionHeaderWriter: drop per-request tuple boxing in OnStarting
  by re-fetching endpoint metadata + writer from RequestServices in a
  static callback; expose WriteVersioningHeadersTo as a public helper
  so exception-path middleware can reuse the same RFC formatting.
- ApiVersioningPolicy: rename WireHeaderPostprocessors to
  AttachHeaderState, RequiresHeaderWriter to RequiresHeaderEmission,
  _headerProcessedChains to _headerStateChains.
- WolverineWebApi: split OrdersV1ErrorPathsEndpoint.cs into one file
  per type; add OrdersV1ThrowsEndpoint plus a scoped UseExceptionHandler
  on /v1/orders/throws to back the new regression test.
- api_versioning_error_path_header_tests: pin the documented
  out-of-scope (5xx via global exception handler emits no versioning
  headers) and tighten api-supported-versions assertions to the exact
  expected value rather than ContainsKey.
- versioning.md: tighten the 5xx middleware snippet to delegate header
  emission to ApiVersionHeaderWriter.WriteVersioningHeadersTo so the
  source of truth lives in one place.
…eader path

Second-pass review fixes for PR #3.

Production
- Rename ApiVersioningPolicy.ChainsRequiringHeaderWriter to
  ChainsRequiringHeaderEmission to match the rest of the rename pass
  ("header state / emission" terminology). Property was already internal,
  so callers in WolverineHttpEndpointRouteBuilderExtensions are updated
  in lockstep.
- ApiVersionHeaderWriter.WriteAsync now resolves the writer via
  GetRequiredService<T>() inside the OnStarting callback. The previous
  GetService(typeof(...)) silently swallowed null and made lost headers
  invisible. Bootstrap registers the writer unconditionally as a singleton,
  so a missing registration is a programmer error and must fail fast.
- Add inline comments to the early-exit gate and the in-OnStarting re-fetch
  explaining why the writer must re-resolve the endpoint at flush time
  (middleware can re-route the request between frames).
- Mark WriteVersioningHeadersTo with EditorBrowsable(Advanced) and add a
  class-level XML doc explaining the asymmetry between WriteAsync (chain
  frame, locked by codegen) and the sync helper for advanced/middleware use.
- WolverineWebApi/Program.cs: pin placement intent of UseExceptionHandler
  with a "before MapWolverineEndpoints" comment.
- OrdersV1ThrowsEndpoint: throw message now identifies the source and
  flags it as IGNORE for log tailers.

Tests
- New ApiVersionHeaderWriterTests.missing_writer_in_request_services_throws
  pins the new fail-fast contract for the GetRequiredService change.
- New DEBUG-only ApiVersioningPolicyHeaderWiringTests.finalization_assert_-
  fires_when_writer_was_displaced exercises the Debug.Assert invariant
  in ApiVersionHeaderFinalizationPolicy via a throwing TraceListener.
  Guarded by #if DEBUG since the assert is no-op under RELEASE.
- api_versioning_error_path_header_tests:
  - Extract ExpectedSupportedVersions constant for "1.0, 3.0" with a
    pointer to Program.cs:303-306 so config drift produces a clear
    single failure rather than four near-identical ones.
  - Assert response body contains "global-exception-handler" on the 5xx
    test so a future change letting Wolverine answer the throws endpoint
    with its own 500 cannot turn the absent-headers assertion into a
    tautology.

Docs
- versioning.md exception-handler snippet: add the missing
  Microsoft.Extensions.DependencyInjection using and switch to
  GetRequiredService<T>() to match the production change.
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.

1 participant