Skip to content
118 changes: 90 additions & 28 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ extension HTTPClient {
request,
deadline: deadline,
logger: logger ?? Self.loggingDisabled,
redirectState: RedirectState(self.configuration.redirectConfiguration.mode, initialURL: request.url)
redirectMode: self.configuration.redirectConfiguration.mode
)
}
}
Expand Down Expand Up @@ -88,10 +88,11 @@ extension HTTPClient {
_ request: HTTPClientRequest,
deadline: NIODeadline,
logger: Logger,
redirectState: RedirectState?
redirectMode: HTTPClient.Configuration.RedirectConfiguration.Mode
) async throws -> HTTPClientResponse {
var currentRequest = request
var currentRedirectState = redirectState
var currentRedirectState = RedirectState(redirectMode, initialURL: request.url)
var customRedirectCount = 0
var history: [HTTPClientRequestResponse] = []

// this loop is there to follow potential redirects
Expand Down Expand Up @@ -122,39 +123,100 @@ extension HTTPClient {
return response
}()

guard var redirectState = currentRedirectState else {
// a `nil` redirectState means we should not follow redirects
switch redirectMode {
case .disallow:
return response
}

guard
let redirectURL = response.headers.extractRedirectTarget(
case .follow:
guard case .follow(var followState)? = currentRedirectState else {
// a `nil` redirectState means we should not follow redirects
return response
}

guard
let redirectURL = response.headers.extractRedirectTarget(
status: response.status,
originalURL: preparedRequest.url,
originalScheme: preparedRequest.poolKey.scheme
)
else {
// response does not want a redirect
return response
}

// validate that we do not exceed any limits or are running circles
try followState.redirect(to: redirectURL.absoluteString)
currentRedirectState = .follow(followState)

let newRequest = currentRequest.followingRedirect(
from: preparedRequest.url,
to: redirectURL,
status: response.status,
originalURL: preparedRequest.url,
originalScheme: preparedRequest.poolKey.scheme
config: followState.config
)
else {
// response does not want a redirect
return response
}

// validate that we do not exceed any limits or are running circles
try redirectState.redirect(to: redirectURL.absoluteString)
currentRedirectState = redirectState
guard newRequest.body.canBeConsumedMultipleTimes else {
// we already send the request body and it cannot be send again
return response
}

let newRequest = currentRequest.followingRedirect(
from: preparedRequest.url,
to: redirectURL,
status: response.status,
config: redirectState.config
)
currentRequest = newRequest

guard newRequest.body.canBeConsumedMultipleTimes else {
// we already send the request body and it cannot be send again
return response
}
case .strategy(let anyStrategy):
let strategy = anyStrategy as! any HTTPClientRedirectStrategy
guard
let redirectURL = response.headers.extractRedirectTarget(
status: response.status,
originalURL: preparedRequest.url,
originalScheme: preparedRequest.poolKey.scheme
)
else {
// response does not want a redirect
return response
}

// Pre-build the request the same way `.follow` would, applying the standard
// method/header rewrite rules, so the strategy only needs to make further
// adjustments rather than reimplement those rules itself. `max`/`allowCycles`
// are irrelevant here: only the `retainHTTPMethodAndBodyOn30{1,2}` flags feed
// into this transformation, and there's no built-in limit in `.strategy` mode.
let candidateRequest = currentRequest.followingRedirect(
from: preparedRequest.url,
to: redirectURL,
status: response.status,
config: .init(
max: 0,
allowCycles: true,
retainHTTPMethodAndBodyOn301: false,
retainHTTPMethodAndBodyOn302: false
)
)

currentRequest = newRequest
let context = HTTPClientRedirectContext(
redirectRequest: candidateRequest,
response: HTTPResponseHead(
version: response.version,
status: response.status,
headers: response.headers
),
history: history,
redirectCount: customRedirectCount
)

switch try strategy.redirectDecision(for: context) {
case .doNotFollow:
return response

case .follow(let newRequest):
guard newRequest.body.canBeConsumedMultipleTimes else {
// we already send the request body and it cannot be send again
return response
}

customRedirectCount += 1
currentRequest = newRequest
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIOHTTP1

/// A pluggable strategy for deciding whether — and how — to follow HTTP redirects, used via
/// ``HTTPClient/Configuration/RedirectConfiguration/strategy(_:)``.
///
/// Unlike `.disallow`/`.follow(max:allowCycles:)`, a strategy gets a chance to inspect every
/// redirect-eligible response before it's followed: adjust the outgoing request, refuse the redirect
/// outright, or fail the whole request with a custom error.
///
/// A single strategy instance is stored on ``HTTPClient/Configuration`` and reused for every request
/// that client makes, including concurrently — if your strategy holds mutable state (e.g. an audit
/// log, a shared allow-list), synchronize it yourself (an `actor`, or a class using a lock).
/// Per-request state doesn't need that: ``HTTPClientRedirectContext/history`` already carries
/// everything tracked so far for the *current* logical request, so most policies (host allow-listing,
/// loop bounds, auditing) can be implemented statelessly by reading it fresh on each call.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public protocol HTTPClientRedirectStrategy: Sendable {
/// Decide whether — and how — to follow a redirect.
///
/// - Parameter context: Everything known about the redirect so far. See
/// ``HTTPClientRedirectContext``.
/// - Returns: Whether — and with what request — to follow the redirect.
/// - Throws: To fail the whole `execute(...)` call with a custom error instead of following the
/// redirect or returning the response that triggered it.
func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision
}

/// Everything a ``HTTPClientRedirectStrategy`` is handed to decide whether — and how — to follow one
/// redirect.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public struct HTTPClientRedirectContext: Sendable {
/// The request that would be sent to follow the redirect. It has already gone through the same
/// method/header rewrite rules `.follow` would apply (converting `POST` to `GET` on a 303,
/// stripping `Authorization`/`Cookie`/`Origin`/`Proxy-Authorization` on cross-origin redirects) —
/// you only need to make further adjustments, not reimplement those rules from scratch.
public var redirectRequest: HTTPClientRequest

/// The head of the response that triggered the redirect.
public var response: HTTPResponseHead

/// Every request/response pair sent so far for this logical request, oldest first, including the
/// one that produced ``response``. This is the same data that ends up in
/// ``HTTPClientResponse/history`` on the final response.
public var history: [HTTPClientRequestResponse]

/// How many redirects have already been followed for this logical request (equivalently,
/// `history.count - 1`). There is no built-in limit for `.strategy`/`.custom` mode — enforce your
/// own policy (e.g. refusing past a maximum count) to avoid infinite redirect loops.
public var redirectCount: Int

public init(
redirectRequest: HTTPClientRequest,
response: HTTPResponseHead,
history: [HTTPClientRequestResponse],
redirectCount: Int
) {
self.redirectRequest = redirectRequest
self.response = response
self.history = history
self.redirectCount = redirectCount
}
}

/// The result of a ``HTTPClientRedirectStrategy`` deciding whether — and how — to follow a redirect.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public enum HTTPClientRedirectDecision: Sendable {
/// Follow the redirect using the given request.
case follow(HTTPClientRequest)
/// Do not follow the redirect; the response that triggered it is returned as-is.
case doNotFollow
}

/// Adapts a closure to ``HTTPClientRedirectStrategy``, backing
/// ``HTTPClient/Configuration/RedirectConfiguration/custom(_:)``.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
struct ClosureRedirectStrategy: HTTPClientRedirectStrategy {
let handler: @Sendable (HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision

func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision {
try self.handler(context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ extension HTTPClientRequest.Prepared.Body {
)
case .byteBuffer(let byteBuffer):
self = .byteBuffer(byteBuffer)
case .delegateBody:
// Only ever produced by the delegate-based redirect-strategy bridge
// (`RedirectStrategyDelegateBridge.swift`), which converts it back into a
// `HTTPClient.Body` (`asDelegateBody()`) rather than routing it through the
// Concurrency API's own request preparation.
fatalError(
"`.delegateBody` never reaches `HTTPClientRequest.Prepared` -- it is unwrapped via `asDelegateBody()` instead."
)
#if UnstableHTTPAPIsSupport
case .httpClientRequestBody(let length, let requestBody):
self = .httpClientRequestBody(length, requestBody)
Expand All @@ -132,6 +140,10 @@ extension RequestBodyLength {
self = .known(Int64(buffer.readableBytes))
case .sequence(let length, _, _), .asyncSequence(let length, _):
self = length
case .delegateBody:
fatalError(
"`.delegateBody` never reaches `HTTPClientRequest.Prepared` -- it is unwrapped via `asDelegateBody()` instead."
)
#if UnstableHTTPAPIsSupport
case .httpClientRequestBody(let length, _):
self = length
Expand Down
17 changes: 17 additions & 0 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ extension HTTPClientRequest {
)
case byteBuffer(ByteBuffer)

/// Wraps a delegate-based-API ``HTTPClient/Body`` verbatim, untouched.
///
/// Exists solely so the delegate-based `execute(request:delegate:...)` API can offer a
/// redirect-eligible request's body to a ``HTTPClientRedirectStrategy`` (which is typed
/// in terms of this Concurrency-API `Body`, not that one) without re-encoding it -- see
/// `RedirectStrategyDelegateBridge.swift`. `canBeConsumedMultipleTimes` is conservatively
/// `false`: a delegate-API `Body`'s `stream` closure isn't guaranteed replayable. Never
/// produced by any public factory, and never asked to iterate -- it is unwrapped back
/// into a `HTTPClient.Body` (`asDelegateBody()`) before it would ever need to stream.
case delegateBody(HTTPClient.Body)

#if UnstableHTTPAPIsSupport
case httpClientRequestBody(
length: RequestBodyLength,
Expand Down Expand Up @@ -398,6 +409,7 @@ extension Optional where Wrapped == HTTPClientRequest.Body {
case .byteBuffer: return true
case .sequence(_, let canBeConsumedMultipleTimes, _): return canBeConsumedMultipleTimes
case .asyncSequence: return false
case .delegateBody: return false
#if UnstableHTTPAPIsSupport
case .httpClientRequestBody: return false // TODO: I think this should be TRUE
#endif
Expand Down Expand Up @@ -441,6 +453,11 @@ extension HTTPClientRequest.Body: AsyncSequence {
return .init(storage: .byteBuffer(makeCompleteBody(AsyncIterator.allocator)))
case .byteBuffer(let byteBuffer):
return .init(storage: .byteBuffer(byteBuffer))
case .delegateBody:
// Only ever produced by the delegate-based redirect-strategy bridge, which unwraps
// it back into a `HTTPClient.Body` (`asDelegateBody()`) instead of ever asking this
// AsyncSequence conformance to iterate it.
fatalError("`.delegateBody` is never iterated -- it is unwrapped via `asDelegateBody()` instead.")
#if UnstableHTTPAPIsSupport
case .httpClientRequestBody:
fatalError("Unimplemented")
Expand Down
Loading
Loading