Skip to content

Add pluggable HTTPClientRedirectStrategy, for both execute APIs - #9

Draft
o-nnerb wants to merge 7 commits into
mainfrom
feature/redirect-custom-handler
Draft

o-nnerb wants to merge 7 commits into
mainfrom
feature/redirect-custom-handler

Conversation

@o-nnerb

@o-nnerb o-nnerb commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds a pluggable HTTPClientRedirectStrategy redirect configuration (.strategy(_:)/.custom(_:)), which gets a chance to inspect every redirect-eligible response before it's followed -- adjust the outgoing request, refuse the redirect, or fail the whole request with a custom error. Complements the fixed-limit .follow(max:allowCycles:).

Supported on both execute APIs:

  • The Swift Concurrency execute(_:deadline:logger:) family (HTTPClientRequest/HTTPClientResponse) -- the original implementation.
  • The delegate-based execute(request:delegate:...) family (HTTPClient.Request/HTTPClientResponseDelegate) -- added on top, since a caller whose whole pipeline is built on the delegate-based API (streaming responses through a custom HTTPClientResponseDelegate) otherwise had no way to use .strategy at all, not even for the same follow/rewrite/refuse decisions .follow already supports there. RedirectHandler.redirect builds a HTTPClientRedirectContext and drives the configured strategy, converting between the two APIs' request/body types (RedirectStrategyDelegateBridge.swift) -- a strategy's untouched redirectRequest.body is reused verbatim, .bytes/.byteBuffer replacements are drained losslessly and synchronously, and a genuinely new streaming body throws a clear HTTPClientError.redirectStrategyBodyNotSupported since nothing at redirect-decision time can drain one.

Known limitation, documented in code: .doNotFollow fails the task (.invalidRedirectConfiguration) on the delegate-based path specifically. Honoring it there would mean resuming normal response delivery for a response the delegate-based state machine already committed to treating as a redirect candidate, which that state machine has no route back from today (.follow's own redirect-limit/cycle errors fail the whole task the same way). Works fully on the Concurrency API.

See discussion: swift-server#923

Test plan

  • swift build / swift build --build-tests clean
  • swift-format lint --recursive --strict clean across the whole tree
  • Full swift test suite passing
  • End-to-end tests for both APIs: inspect+follow, inspect+rewrite, refuse (.doNotFollow), redirect-count/history tracking, cycle detection, and (delegate API only) that configuring .strategy doesn't reject requests that never redirect

🤖 Generated with Claude Code

o-nnerb and others added 7 commits September 3, 2026 15:42
Adds RedirectConfiguration.custom(_:), letting callers intercept every
redirect-eligible response and decide whether/how to follow it instead
of being limited to disallow/follow(max:allowCycles:). The candidate
request handed to the handler has already gone through the same
method/header rewrite rules `.follow` applies (POST->GET on 303,
stripping Authorization/Cookie/Origin/Proxy-Authorization cross-origin),
so callers only need to make further adjustments — e.g. stripping
additional sensitive headers before a cross-host redirect is followed.

Wired into the Swift Concurrency execute(_:deadline:logger:) family
only; the delegate-based execute(request:delegate:...) API fails fast
with .invalidRedirectConfiguration since it has no HTTPClientRequest to
hand the handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tegy

Replaces the bare-closure `.custom(_:)` from the previous commit with
HTTPClientRedirectStrategy, a protocol callers can conform their own
types to (not just closures), addressing the two gaps a maintainer
flagged on the upstream issue thread: pluggable strategies as actual
types, and access to more than a bare redirect count — the strategy
now receives the full per-request history alongside the candidate
request and response.

- HTTPClientRedirectContext bundles redirectRequest/response/history/
  redirectCount into one value instead of four positional parameters.
- HTTPClientRedirectStrategy.redirectDecision(for:) can throw, so a
  strategy can fail the whole execute() call with a custom error
  instead of only following/refusing.
- RedirectConfiguration.strategy(_:) is the primary entry point;
  .custom(_:) remains as a closure-based convenience over it via an
  internal ClosureRedirectStrategy adapter.
- Mode.custom renamed to Mode.strategy to match.

Still scoped to the Swift Concurrency execute(_:deadline:logger:)
family only; the delegate-based API continues to fail fast with
.invalidRedirectConfiguration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous two CI runs on this branch failed before any job started
("reference to workflow should be either a valid branch, tag, or
commit") because beta's swift-ci.yaml referenced a since-deleted
request-dl/.github branch. That's now fixed on beta (f360eae); this
empty commit just re-triggers the pull_request check with no code
changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swift disallows @available on an enum case that carries an associated
value, so `Mode.strategy(any HTTPClientRedirectStrategy)` failed to
compile under the API digester's build (which surfaces this check that
normal `swift build` doesn't enforce). Store the payload as `any
Sendable` instead — the same type-erasure trick already used for
`Configuration._tracer` — and downcast at the one call site that needs
the concrete protocol.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e API

`.strategy`/`.custom` redirect handlers were only wired up for the
Concurrency `execute(_:deadline:logger:)` family; the delegate-based
`execute(request:delegate:...)` API fast-failed with
`.invalidRedirectConfiguration` instead. That's fine on its own, but a
caller whose whole pipeline is built on the delegate-based API (streaming
responses through a custom `HTTPClientResponseDelegate`, as
request-dl-nio's `Internals.Client` does) had no way to use `.strategy`
at all -- not even to make the same follow/rewrite/refuse decisions
`.follow` already supports there.

`RedirectState` becomes an enum (`.follow`/`.strategy`) instead of a
struct that only ever modeled `.follow`, and `RedirectHandler.redirect`
now builds a `HTTPClientRedirectContext` and calls the configured
strategy for `.strategy` too, converting between the delegate API's
legacy `HTTPClient.Request`/`Body` and the Concurrency API's
`HTTPClientRequest`/`Body` (`RedirectStrategyLegacyBridge.swift`) --
reusing a strategy's untouched `redirectRequest.body` verbatim via a new
internal `Body.Mode.legacyBody` case, and lossless synchronous draining
for `.bytes`/`.byteBuffer` bodies a strategy replaces it with.

`.doNotFollow` is not supported on this path: honoring it would mean
resuming normal response delivery for a response the delegate-based
state machine already committed to treating as a redirect candidate,
which that state machine has no route back from (`.follow`'s own
redirect-limit/cycle errors fail the whole task the same way, they never
resume delivery either). It fails with `.invalidRedirectConfiguration`
instead, same as before this change, with a doc comment pointing at the
Concurrency API for callers that need it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AsyncHTTPClient doesn't call HTTPClient.Request/execute(request:delegate:...)
"legacy" anywhere -- it's the older of the two execute APIs, but it isn't
deprecated and isn't being phased out, so labeling it that way in the new
bridge code (file name, the Body.Mode case, doc comments) was misleading.
Renamed throughout to name it by what it actually is: the delegate-based
API, as opposed to the Concurrency one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously, a .strategy redirect configuration deciding .doNotFollow on
the delegate-based execute(request:delegate:...) API always failed the
task instead of delivering the response that triggered the redirect --
the state machine (RequestBag+StateMachine.swift) decided whether a
response was a redirect candidate, and either discarded its body after
counting (<=3KB) or cancelled the connection before reading any of it
(known >3KB), before the strategy's own decision was ever computed.

RedirectHandler.earlyStrategyDecision(head:) now asks the strategy the
moment the response head arrives, before any body byte is touched --
HTTPClientRedirectContext never carries a body, so nothing about it is
needed at that point. `.doNotFollow` then simply proceeds like any
ordinary, non-redirect-candidate response, regardless of size. A
`.follow(_:)` decision is carried on a copy of the handler (`precomputed`)
into the existing size-based buffer-or-cancel mechanism, which still
reuses the HTTP/1.1 connection for small bodies exactly as before --
this only changes *when* the strategy is asked, not what happens once it
decides to follow.

The strategy call had to move outside receiveResponseHead's own mutating
access to the state machine: it's arbitrary caller code that can
synchronously reenter the task (e.g. call task.cancel()), which would
otherwise overlap two accesses to the same storage and trap under Swift's
exclusivity enforcement. RequestBag.receiveResponseHead0 now computes the
decision first and hands the state machine only the answer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Additive, non-breaking API change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant