Skip to content
Open
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
117 changes: 89 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,99 @@ 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 var redirectState = 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 redirectState.redirect(to: redirectURL.absoluteString)
currentRedirectState = redirectState

let newRequest = currentRequest.followingRedirect(
from: preparedRequest.url,
to: redirectURL,
status: response.status,
originalURL: preparedRequest.url,
originalScheme: preparedRequest.poolKey.scheme
config: redirectState.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 strategy):
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)
}
}
45 changes: 44 additions & 1 deletion Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,23 @@ public final class HTTPClient: Sendable {
]
)

if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *),
case .strategy = self.configuration.redirectConfiguration.mode
{
// `.strategy`/`.custom` redirect handlers operate on `HTTPClientRequest`/`HTTPClientResponse`
// and are only wired up for the Swift Concurrency `execute(_:deadline:logger:)` family of APIs.
logger.debug(
"`.strategy` redirect configuration is not supported by the delegate-based execute API, failing request"
)
return Task<Delegate.Response>.failedTask(
eventLoop: taskEL,
error: HTTPClientError.invalidRedirectConfiguration,
logger: logger,
tracing: tracing,
makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool
)
}

let failedTask: Task<Delegate.Response>? = self.state.withLockedValue { state -> (Task<Delegate.Response>?) in
switch state {
case .upAndRunning:
Expand Down Expand Up @@ -1312,11 +1329,14 @@ extension HTTPClient.Configuration {

/// Specifies redirect processing settings.
public struct RedirectConfiguration: Sendable {
enum Mode: Hashable {
enum Mode {
/// Redirects are not followed.
case disallow
/// Redirects are followed with a specified limit.
case follow(FollowConfiguration)
/// Redirects are handed to a pluggable ``HTTPClientRedirectStrategy``.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
case strategy(any HTTPClientRedirectStrategy)
}

/// Configuration for following redirects.
Expand Down Expand Up @@ -1397,6 +1417,29 @@ extension HTTPClient.Configuration {
public static func follow(configuration: FollowConfiguration) -> RedirectConfiguration {
.init(configuration: .follow(configuration))
}

/// Redirects are handed to a pluggable strategy, which decides whether and how to follow each
/// one. See ``HTTPClientRedirectStrategy``.
///
/// - warning: There is no built-in redirect-count or cycle limit for this mode — use the
/// `redirectCount`/`history` passed to the strategy to enforce your own policy.
/// - note: Only supported by the Swift Concurrency `execute(_:deadline:logger:)` family of APIs.
/// Using `.strategy`/`.custom` with the delegate-based `execute(request:delegate:...)` API
/// fails with ``HTTPClientError/invalidRedirectConfiguration``.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public static func strategy(_ strategy: any HTTPClientRedirectStrategy) -> RedirectConfiguration {
.init(configuration: .strategy(strategy))
}

/// Convenience over ``strategy(_:)`` for a policy that doesn't need its own type: redirects are
/// handed to `handler`, which decides whether and how to follow each one. See
/// ``HTTPClientRedirectContext`` for what `handler` receives.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public static func custom(
_ handler: @escaping @Sendable (HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision
) -> RedirectConfiguration {
.strategy(ClosureRedirectStrategy(handler: handler))
}
}

/// Connection pool configuration.
Expand Down
35 changes: 35 additions & 0 deletions Sources/AsyncHTTPClient/RedirectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,37 @@ import struct Foundation.URL

typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode

// `Mode` can't derive `Equatable`/`Hashable` because `.strategy` carries an existential. `.strategy`
// values have no meaningful notion of equality, so — like `NaN` — a `.strategy` value is never equal
// to any other value, including another `.strategy`; this is consistent (if vacuously so) with the
// `Hashable` requirement that equal values hash equally.
extension HTTPClient.Configuration.RedirectConfiguration.Mode: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.disallow, .disallow):
return true
case (.follow(let lhsConfig), .follow(let rhsConfig)):
return lhsConfig == rhsConfig
default:
return false
}
}
}

extension HTTPClient.Configuration.RedirectConfiguration.Mode: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .disallow:
hasher.combine(0)
case .follow(let config):
hasher.combine(1)
hasher.combine(config)
case .strategy:
hasher.combine(2)
}
}
}

struct RedirectState {
var config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration

Expand All @@ -42,6 +73,10 @@ extension RedirectState {
return nil
case .follow(let config):
self.init(config: config, visited: [initialURL])
case .strategy:
// `.strategy` redirects are handled entirely by the caller-supplied strategy; there is no
// count/cycle state for `RedirectState` to track.
return nil
}
}
}
Expand Down
Loading
Loading