From 2a4026ff71fae62ca997704f92e5ced33903490c Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Mon, 16 Feb 2026 10:49:04 +0000 Subject: [PATCH 1/8] Add option to retain request method on 301/302/303 redirects --- .../AsyncAwait/HTTPClient+execute.swift | 3 +- .../HTTPClientRequest+Prepared.swift | 6 +- Sources/AsyncHTTPClient/HTTPClient.swift | 37 +++++++- ...ientConfiguration+SwiftConfiguration.swift | 5 +- Sources/AsyncHTTPClient/HTTPHandler.swift | 3 +- Sources/AsyncHTTPClient/RedirectState.swift | 19 +++- .../AsyncAwaitEndToEndTests.swift | 89 +++++++++++++++++++ .../HTTPClientTestUtils.swift | 10 +++ .../HTTPClientTests.swift | 68 ++++++++++++++ .../RequestBagTests.swift | 6 +- .../SwiftConfigurationTests.swift | 15 ++-- 11 files changed, 241 insertions(+), 20 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index bbf8c948c..d7faacc5d 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -140,7 +140,8 @@ extension HTTPClient { let newRequest = currentRequest.followingRedirect( from: preparedRequest.url, to: redirectURL, - status: response.status + status: response.status, + convertToGet: redirectState.convertToGet ) guard newRequest.body.canBeConsumedMultipleTimes else { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index e57406e2b..78d0ecd3a 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -124,7 +124,8 @@ extension HTTPClientRequest { func followingRedirect( from originalURL: URL, to redirectURL: URL, - status: HTTPResponseStatus + status: HTTPResponseStatus, + convertToGet: Bool ) -> HTTPClientRequest { let (method, headers, body) = transformRequestForRedirect( from: originalURL, @@ -132,7 +133,8 @@ extension HTTPClientRequest { headers: self.headers, body: self.body, to: redirectURL, - status: status + status: status, + convertToGet: convertToGet ) var newRequest = HTTPClientRequest(url: redirectURL.absoluteString) newRequest.method = method diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 5beba8da6..b53f8dfd8 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1269,13 +1269,35 @@ extension HTTPClient.Configuration { /// Redirects are not followed. case disallow /// Redirects are followed with a specified limit. - case follow(max: Int, allowCycles: Bool) + case follow(FollowConfiguration) + } + + /// Configuration for following redirects. + public struct FollowConfiguration: Hashable, Sendable { + /// The maximum number of allowed redirects. + public var max: Int + /// Whether cycles are allowed. + public var allowCycles: Bool + /// Whether to convert POST requests into GET requests when following a 301, 302 or 303 redirect. + /// This does not apply to 307 or 308. Those always retain the original method. + public var convertToGet: Bool + + /// Create a new ``FollowConfiguration`` + /// - Parameters: + /// - max: The maximum number of allowed redirects. + /// - allowCycles: Whether cycles are allowed. + /// - convertToGet: Whether to convert POST requests into GET requests when following a 301, 302 or 303 redirect. This does not apply to 307 or 308. Those always retain the original method. + public init(max: Int, allowCycles: Bool, convertToGet: Bool) { + self.max = max + self.allowCycles = allowCycles + self.convertToGet = convertToGet + } } var mode: Mode init() { - self.mode = .follow(max: 5, allowCycles: false) + self.mode = .follow(.init(max: 5, allowCycles: false, convertToGet: true)) } init(configuration: Mode) { @@ -1293,7 +1315,16 @@ extension HTTPClient.Configuration { /// /// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector. public static func follow(max: Int, allowCycles: Bool) -> RedirectConfiguration { - .init(configuration: .follow(max: max, allowCycles: allowCycles)) + .follow(configuration: .init(max: max, allowCycles: allowCycles, convertToGet: true)) + } + + /// Redirects are followed with a specified limit. + /// + /// - parameters + /// + /// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector. + public static func follow(configuration: FollowConfiguration) -> RedirectConfiguration { + .init(configuration: .follow(configuration)) } } diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 8adb22d70..5190c4ab3 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -77,7 +77,10 @@ extension HTTPClient.Configuration.RedirectConfiguration { if mode == "follow" { let maxRedirects = configReader.int(forKey: "maxRedirects", default: 5) let allowCycles = configReader.bool(forKey: "allowCycles", default: false) - self = .follow(max: maxRedirects, allowCycles: allowCycles) + let convertToGet = configReader.bool(forKey: "convertToGet", default: true) + self = .follow( + configuration: .init(max: maxRedirects, allowCycles: allowCycles, convertToGet: convertToGet) + ) } else if mode == "disallow" { self = .disallow } else { diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 940cdf40f..54ef25b29 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -1076,7 +1076,8 @@ internal struct RedirectHandler { headers: self.request.headers, body: self.request.body, to: redirectURL, - status: status + status: status, + convertToGet: self.redirectState.convertToGet ) let newRequest = try HTTPClient.Request( diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 95de2d508..a4f858131 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -27,6 +27,9 @@ struct RedirectState { /// if true, `redirect(to:)` will throw an error if a cycle is detected. private let allowCycles: Bool + + /// If true, POST requests are converted to GET for 301, 302, 303 + let convertToGet: Bool } extension RedirectState { @@ -40,8 +43,13 @@ extension RedirectState { switch configuration { case .disallow: return nil - case .follow(let maxRedirects, let allowCycles): - self.init(limit: maxRedirects, visited: [initialURL], allowCycles: allowCycles) + case .follow(let config): + self.init( + limit: config.max, + visited: [initialURL], + allowCycles: config.allowCycles, + convertToGet: config.convertToGet + ) } } } @@ -111,10 +119,13 @@ func transformRequestForRedirect( headers requestHeaders: HTTPHeaders, body requestBody: Body?, to redirectURL: URL, - status responseStatus: HTTPResponseStatus + status responseStatus: HTTPResponseStatus, + convertToGet: Bool ) -> (HTTPMethod, HTTPHeaders, Body?) { let convertToGet: Bool - if responseStatus == .seeOther, requestMethod != .HEAD { + if !convertToGet { + convertToGet = false + } else if responseStatus == .seeOther, requestMethod != .HEAD { convertToGet = true } else if responseStatus == .movedPermanently || responseStatus == .found, requestMethod == .POST { convertToGet = true diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 56a08b852..43430b85c 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -1019,6 +1019,95 @@ final class AsyncAwaitEndToEndTests: XCTestCase { } } } + + // MARK: - POST to GET conversion on redirects + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + private func _testPostConvertedToGetOnRedirect( + statusPath: String, + expectedStatus: HTTPResponseStatus + ) { + XCTAsyncTest { + let bin = HTTPBin(.http2(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + let client = makeDefaultHTTPClient() + defer { XCTAssertNoThrow(try client.syncShutdown()) } + let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:)) + + var request = HTTPClientRequest(url: "https://localhost:\(bin.port)\(statusPath)") + request.method = .POST + request.body = .bytes(ByteBuffer(string: "test body")) + + guard + let response = await XCTAssertNoThrowWithResult( + try await client.execute(request, deadline: .now() + .seconds(10), logger: logger) + ) + else { return } + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.history.count, 2, "Expected 2 entries in history for \(statusPath)") + XCTAssertEqual( + response.history[0].request.method, + .POST, + "Original request should be POST for \(statusPath)" + ) + XCTAssertEqual( + response.history[1].request.method, + .GET, + "Redirected request should be converted to GET for \(statusPath)" + ) + XCTAssertEqual( + response.history[0].responseHead.status, + expectedStatus, + "Expected \(expectedStatus) for \(statusPath)" + ) + } + } + + func testPostConvertedToGetOn301Redirect() { + self._testPostConvertedToGetOnRedirect( + statusPath: "/redirect/301", + expectedStatus: .movedPermanently + ) + } + + func testPostConvertedToGetOn302Redirect() { + self._testPostConvertedToGetOnRedirect( + statusPath: "/redirect/302", + expectedStatus: .found + ) + } + + func testPostConvertedToGetOn303Redirect() { + self._testPostConvertedToGetOnRedirect( + statusPath: "/redirect/303", + expectedStatus: .seeOther + ) + } + + func testGetMethodUnchangedOnRedirect() { + XCTAsyncTest { + let bin = HTTPBin(.http2(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + let client = makeDefaultHTTPClient() + defer { XCTAssertNoThrow(try client.syncShutdown()) } + let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:)) + + // Test that non-POST methods remain unchanged + let requestGet = HTTPClientRequest(url: "https://localhost:\(bin.port)/redirect/302") + + guard + let responseGet = await XCTAssertNoThrowWithResult( + try await client.execute(requestGet, deadline: .now() + .seconds(10), logger: logger) + ) + else { return } + + XCTAssertEqual(responseGet.status, .ok) + XCTAssertEqual(responseGet.history.count, 2) + XCTAssertEqual(responseGet.history[0].request.method, .GET, "Original request should be GET") + XCTAssertEqual(responseGet.history[1].request.method, .GET, "Redirected request should remain GET") + } + } } struct AnySendableSequence: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index 689b4358e..2dcce3e12 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -984,11 +984,21 @@ internal final class HTTPBinHandler: ChannelInboundHandler { } self.resps.append(HTTPResponseBuilder(status: .ok, responseBodyIsRequestBodyByteCount: true)) return + case "/redirect/301": + var headers = self.responseHeaders + headers.add(name: "location", value: "/ok") + self.resps.append(HTTPResponseBuilder(status: .movedPermanently, headers: headers)) + return case "/redirect/302": var headers = self.responseHeaders headers.add(name: "location", value: "/ok") self.resps.append(HTTPResponseBuilder(status: .found, headers: headers)) return + case "/redirect/303": + var headers = self.responseHeaders + headers.add(name: "location", value: "/ok") + self.resps.append(HTTPResponseBuilder(status: .seeOther, headers: headers)) + return case "/redirect/https": let port = self.value(for: "port", from: urlComponents.query!) var headers = self.responseHeaders diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 054cf3487..a3e1c71c2 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4575,6 +4575,74 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual($0 as? HTTPClientError, .connectTimeout) } } + + private func _testPostConvertedToGetOnRedirect( + statusPath: String, + expectedStatus: HTTPResponseStatus, + convertPostToGET: Bool + ) throws { + let bin = HTTPBin(.http1_1()) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(self.clientGroup), + configuration: HTTPClient.Configuration( + redirectConfiguration: .follow( + configuration: .init(max: 10, allowCycles: false, convertPostToGET: convertPostToGET) + ) + ) + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + let request = try HTTPClient.Request( + url: "http://localhost:\(bin.port)\(statusPath)", + method: .POST, + headers: HTTPHeaders(), + body: .string("test body") + ) + let response = try localClient.execute(request: request).wait() + XCTAssertEqual(response.status, .ok) + guard response.history.count == 2 else { + return XCTFail("Expected 2 entries in history for \(statusPath)") + } + XCTAssertEqual(response.history[0].request.method, .POST) + if convertPostToGET { + XCTAssertEqual(response.history[1].request.method, .GET) + } else { + XCTAssertEqual(response.history[1].request.method, .POST) + } + XCTAssertEqual(response.history[0].responseHead.status, expectedStatus) + } + + func testPostConvertedToGetOn301Redirect() throws { + for convertPostToGET in [true, false] { + try _testPostConvertedToGetOnRedirect( + statusPath: "/redirect/301", + expectedStatus: .movedPermanently, + convertPostToGET: convertPostToGET + ) + } + } + + func testPostConvertedToGetOn302Redirect() throws { + for convertPostToGET in [true, false] { + try _testPostConvertedToGetOnRedirect( + statusPath: "/redirect/302", + expectedStatus: .found, + convertPostToGET: convertPostToGET + ) + } + } + + func testPostConvertedToGetOn303Redirect() throws { + for convertPostToGET in [true, false] { + try _testPostConvertedToGetOnRedirect( + statusPath: "/redirect/303", + expectedStatus: .seeOther, + convertPostToGET: convertPostToGET + ) + } + } } final class CountingDebugInitializerUtil: Sendable { diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 51621c7a6..0cb81b449 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -731,7 +731,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(max: 5, allowCycles: false), + .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -819,7 +819,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(max: 5, allowCycles: false), + .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -881,7 +881,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(max: 5, allowCycles: false), + .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 8e9c97276..93159f69b 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -29,6 +29,7 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 10, "redirect.allowCycles": true, + "redirect.convertPostToGET": false, "timeout.connectionMs": 5000, "timeout.readMs": 30000, @@ -51,9 +52,10 @@ struct HTTPClientConfigurationPropsTests { #expect(config.dnsOverride["example.com"] == "192.168.1.1") switch config.redirectConfiguration.mode { - case .follow(let max, let allowCycles): - #expect(max == 10) - #expect(allowCycles) + case .follow(let follow): + #expect(follow.max == 10) + #expect(follow.allowCycles) + #expect(!follow.convertPostToGET) case .disallow: Issue.record("Unexpected value") } @@ -245,7 +247,7 @@ struct HTTPClientConfigurationPropsTests { let configReader = ConfigReader(provider: testProvider) let config = try HTTPClient.Configuration(configReader: configReader) - #expect(config.redirectConfiguration.mode == .follow(max: 5, allowCycles: false)) + #expect(config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertPostToGET: true))) } @Test @@ -255,13 +257,16 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 3, "redirect.allowCycles": true, + "redirect.convertPostToGET": false, ]) let configReader = ConfigReader(provider: testProvider) let config = try HTTPClient.Configuration(configReader: configReader) - #expect(config.redirectConfiguration.mode == .follow(max: 3, allowCycles: true)) + #expect( + config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertPostToGET: false)) + ) } @Test From 2eb41f5ca02423e84d02da0f1346a2930e976778 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Tue, 17 Feb 2026 11:30:46 +0000 Subject: [PATCH 2/8] Separate options for 301/2/3 --- .../AsyncAwait/HTTPClient+execute.swift | 4 ++- .../HTTPClientRequest+Prepared.swift | 8 +++-- Sources/AsyncHTTPClient/HTTPClient.swift | 26 +++++++++----- ...ientConfiguration+SwiftConfiguration.swift | 15 ++++++-- Sources/AsyncHTTPClient/HTTPHandler.swift | 4 ++- Sources/AsyncHTTPClient/RedirectState.swift | 30 ++++++++++------ .../HTTPClientTests.swift | 36 ++++++++++++++----- .../RequestBagTests.swift | 6 ++-- .../SwiftConfigurationTests.swift | 24 ++++++++++--- 9 files changed, 112 insertions(+), 41 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index d7faacc5d..327133ec9 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -141,7 +141,9 @@ extension HTTPClient { from: preparedRequest.url, to: redirectURL, status: response.status, - convertToGet: redirectState.convertToGet + convertToGetOn301: redirectState.convertToGetOn301, + convertToGetOn302: redirectState.convertToGetOn302, + convertToGetOn303: redirectState.convertToGetOn303 ) guard newRequest.body.canBeConsumedMultipleTimes else { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 78d0ecd3a..9ad5c2dcd 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -125,7 +125,9 @@ extension HTTPClientRequest { from originalURL: URL, to redirectURL: URL, status: HTTPResponseStatus, - convertToGet: Bool + convertToGetOn301: Bool, + convertToGetOn302: Bool, + convertToGetOn303: Bool ) -> HTTPClientRequest { let (method, headers, body) = transformRequestForRedirect( from: originalURL, @@ -134,7 +136,9 @@ extension HTTPClientRequest { body: self.body, to: redirectURL, status: status, - convertToGet: convertToGet + convertToGetOn301: convertToGetOn301, + convertToGetOn302: convertToGetOn302, + convertToGetOn303: convertToGetOn303 ) var newRequest = HTTPClientRequest(url: redirectURL.absoluteString) newRequest.method = method diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index b53f8dfd8..70b933481 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1278,26 +1278,36 @@ extension HTTPClient.Configuration { public var max: Int /// Whether cycles are allowed. public var allowCycles: Bool - /// Whether to convert POST requests into GET requests when following a 301, 302 or 303 redirect. - /// This does not apply to 307 or 308. Those always retain the original method. - public var convertToGet: Bool + /// Whether to convert POST requests into GET requests when following a 301 redirect. + /// This should be true as per the HTTP spec. Only change this if you know what you're doing. + public var convertToGetOn301: Bool + /// Whether to convert POST requests into GET requests when following a 302 redirect. + /// This should be true as per the HTTP spec. Only change this if you know what you're doing. + public var convertToGetOn302: Bool + /// Whether to convert POST requests into GET requests when following a 303 redirect. + /// This should be true as per the HTTP spec. Only change this if you know what you're doing. + public var convertToGetOn303: Bool /// Create a new ``FollowConfiguration`` /// - Parameters: /// - max: The maximum number of allowed redirects. /// - allowCycles: Whether cycles are allowed. - /// - convertToGet: Whether to convert POST requests into GET requests when following a 301, 302 or 303 redirect. This does not apply to 307 or 308. Those always retain the original method. - public init(max: Int, allowCycles: Bool, convertToGet: Bool) { + /// - convertToGetOn301: Whether to convert POST requests into GET requests when following a 301 redirect. + /// - convertToGetOn302: Whether to convert POST requests into GET requests when following a 302 redirect. + /// - convertToGetOn303: Whether to convert POST requests into GET requests when following a 303 redirect. + public init(max: Int, allowCycles: Bool, convertToGetOn301: Bool, convertToGetOn302: Bool, convertToGetOn303: Bool) { self.max = max self.allowCycles = allowCycles - self.convertToGet = convertToGet + self.convertToGetOn301 = convertToGetOn301 + self.convertToGetOn302 = convertToGetOn302 + self.convertToGetOn303 = convertToGetOn303 } } var mode: Mode init() { - self.mode = .follow(.init(max: 5, allowCycles: false, convertToGet: true)) + self.mode = .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)) } init(configuration: Mode) { @@ -1315,7 +1325,7 @@ extension HTTPClient.Configuration { /// /// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector. public static func follow(max: Int, allowCycles: Bool) -> RedirectConfiguration { - .follow(configuration: .init(max: max, allowCycles: allowCycles, convertToGet: true)) + .follow(configuration: .init(max: max, allowCycles: allowCycles, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)) } /// Redirects are followed with a specified limit. diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 5190c4ab3..05c300943 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -66,6 +66,9 @@ extension HTTPClient.Configuration.RedirectConfiguration { /// - `mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). /// - `maxRedirects` (int, optional, default: 5): Maximum allowed redirects when mode is "follow". /// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow". + /// - `convertToGetOn301` (bool, optional, default: true): Convert to GET on 301 redirect when mode is "follow". + /// - `convertToGetOn302` (bool, optional, default: true): Convert to GET on 302 redirect when mode is "follow". + /// - `convertToGetOn303` (bool, optional, default: true): Convert to GET on 303 redirect when mode is "follow". /// /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid. public init(configReader: ConfigReader) throws { @@ -77,9 +80,17 @@ extension HTTPClient.Configuration.RedirectConfiguration { if mode == "follow" { let maxRedirects = configReader.int(forKey: "maxRedirects", default: 5) let allowCycles = configReader.bool(forKey: "allowCycles", default: false) - let convertToGet = configReader.bool(forKey: "convertToGet", default: true) + let convertToGetOn301 = configReader.bool(forKey: "convertToGetOn301", default: true) + let convertToGetOn302 = configReader.bool(forKey: "convertToGetOn302", default: true) + let convertToGetOn303 = configReader.bool(forKey: "convertToGetOn303", default: true) self = .follow( - configuration: .init(max: maxRedirects, allowCycles: allowCycles, convertToGet: convertToGet) + configuration: .init( + max: maxRedirects, + allowCycles: allowCycles, + convertToGetOn301: convertToGetOn301, + convertToGetOn302: convertToGetOn302, + convertToGetOn303: convertToGetOn303 + ) ) } else if mode == "disallow" { self = .disallow diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 54ef25b29..205a7baaf 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -1077,7 +1077,9 @@ internal struct RedirectHandler { body: self.request.body, to: redirectURL, status: status, - convertToGet: self.redirectState.convertToGet + convertToGetOn301: self.redirectState.convertToGetOn301, + convertToGetOn302: self.redirectState.convertToGetOn302, + convertToGetOn303: self.redirectState.convertToGetOn303 ) let newRequest = try HTTPClient.Request( diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index a4f858131..07bd5d9d0 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -28,8 +28,14 @@ struct RedirectState { /// if true, `redirect(to:)` will throw an error if a cycle is detected. private let allowCycles: Bool - /// If true, POST requests are converted to GET for 301, 302, 303 - let convertToGet: Bool + /// If true, POST requests are converted to GET for 301 + let convertToGetOn301: Bool + + /// If true, POST requests are converted to GET for 302 + let convertToGetOn302: Bool + + /// If true, POST requests are converted to GET for 303 + let convertToGetOn303: Bool } extension RedirectState { @@ -48,7 +54,9 @@ extension RedirectState { limit: config.max, visited: [initialURL], allowCycles: config.allowCycles, - convertToGet: config.convertToGet + convertToGetOn301: config.convertToGetOn301, + convertToGetOn302: config.convertToGetOn302, + convertToGetOn303: config.convertToGetOn303 ) } } @@ -120,15 +128,17 @@ func transformRequestForRedirect( body requestBody: Body?, to redirectURL: URL, status responseStatus: HTTPResponseStatus, - convertToGet: Bool + convertToGetOn301: Bool, + convertToGetOn302: Bool, + convertToGetOn303: Bool ) -> (HTTPMethod, HTTPHeaders, Body?) { let convertToGet: Bool - if !convertToGet { - convertToGet = false - } else if responseStatus == .seeOther, requestMethod != .HEAD { - convertToGet = true - } else if responseStatus == .movedPermanently || responseStatus == .found, requestMethod == .POST { - convertToGet = true + if responseStatus == .seeOther, requestMethod != .HEAD { + convertToGet = convertToGetOn303 + } else if responseStatus == .movedPermanently, requestMethod == .POST { + convertToGet = convertToGetOn301 + } else if responseStatus == .found, requestMethod == .POST { + convertToGet = convertToGetOn302 } else { convertToGet = false } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index a3e1c71c2..c759c15ba 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4579,7 +4579,10 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { private func _testPostConvertedToGetOnRedirect( statusPath: String, expectedStatus: HTTPResponseStatus, - convertPostToGET: Bool + convertToGetOn301: Bool, + convertToGetOn302: Bool, + convertToGetOn303: Bool, + expectConvert: Bool ) throws { let bin = HTTPBin(.http1_1()) defer { XCTAssertNoThrow(try bin.shutdown()) } @@ -4588,7 +4591,13 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { eventLoopGroupProvider: .shared(self.clientGroup), configuration: HTTPClient.Configuration( redirectConfiguration: .follow( - configuration: .init(max: 10, allowCycles: false, convertPostToGET: convertPostToGET) + configuration: .init( + max: 10, + allowCycles: false, + convertToGetOn301: convertToGetOn301, + convertToGetOn302: convertToGetOn302, + convertToGetOn303: convertToGetOn303 + ) ) ) ) @@ -4606,7 +4615,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return XCTFail("Expected 2 entries in history for \(statusPath)") } XCTAssertEqual(response.history[0].request.method, .POST) - if convertPostToGET { + if expectConvert { XCTAssertEqual(response.history[1].request.method, .GET) } else { XCTAssertEqual(response.history[1].request.method, .POST) @@ -4615,31 +4624,40 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { } func testPostConvertedToGetOn301Redirect() throws { - for convertPostToGET in [true, false] { + for convertToGet in [true, false] { try _testPostConvertedToGetOnRedirect( statusPath: "/redirect/301", expectedStatus: .movedPermanently, - convertPostToGET: convertPostToGET + convertToGetOn301: convertToGet, + convertToGetOn302: true, + convertToGetOn303: true, + expectConvert: convertToGet ) } } func testPostConvertedToGetOn302Redirect() throws { - for convertPostToGET in [true, false] { + for convertToGet in [true, false] { try _testPostConvertedToGetOnRedirect( statusPath: "/redirect/302", expectedStatus: .found, - convertPostToGET: convertPostToGET + convertToGetOn301: true, + convertToGetOn302: convertToGet, + convertToGetOn303: true, + expectConvert: convertToGet ) } } func testPostConvertedToGetOn303Redirect() throws { - for convertPostToGET in [true, false] { + for convertToGet in [true, false] { try _testPostConvertedToGetOnRedirect( statusPath: "/redirect/303", expectedStatus: .seeOther, - convertPostToGET: convertPostToGET + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: convertToGet, + expectConvert: convertToGet ) } } diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 0cb81b449..e08db40c1 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -731,7 +731,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), + .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -819,7 +819,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), + .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -881,7 +881,7 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertPostToGET: true)), + .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), initialURL: request.url.absoluteString )!, execute: { request, _ in diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 93159f69b..2d09d5847 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -29,7 +29,9 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 10, "redirect.allowCycles": true, - "redirect.convertPostToGET": false, + "redirect.convertToGetOn301": false, + "redirect.convertToGetOn302": false, + "redirect.convertToGetOn303": false, "timeout.connectionMs": 5000, "timeout.readMs": 30000, @@ -55,7 +57,9 @@ struct HTTPClientConfigurationPropsTests { case .follow(let follow): #expect(follow.max == 10) #expect(follow.allowCycles) - #expect(!follow.convertPostToGET) + #expect(!follow.convertToGetOn301) + #expect(!follow.convertToGetOn302) + #expect(!follow.convertToGetOn303) case .disallow: Issue.record("Unexpected value") } @@ -247,7 +251,7 @@ struct HTTPClientConfigurationPropsTests { let configReader = ConfigReader(provider: testProvider) let config = try HTTPClient.Configuration(configReader: configReader) - #expect(config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertPostToGET: true))) + #expect(config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true))) } @Test @@ -257,7 +261,9 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 3, "redirect.allowCycles": true, - "redirect.convertPostToGET": false, + "redirect.convertToGetOn301": false, + "redirect.convertToGetOn302": true, + "redirect.convertToGetOn303": false, ]) let configReader = ConfigReader(provider: testProvider) @@ -265,7 +271,15 @@ struct HTTPClientConfigurationPropsTests { let config = try HTTPClient.Configuration(configReader: configReader) #expect( - config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertPostToGET: false)) + config.redirectConfiguration.mode == .follow( + .init( + max: 3, + allowCycles: true, + convertToGetOn301: false, + convertToGetOn302: true , + convertToGetOn303: false + ) + ) ) } From fe09557c82db05d858fb35229f7556414a742037 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Tue, 17 Feb 2026 11:56:24 +0000 Subject: [PATCH 3/8] Pass the entire follow config struct around instead of each parameter --- .../AsyncAwait/HTTPClient+execute.swift | 4 +- .../HTTPClientRequest+Prepared.swift | 8 +--- Sources/AsyncHTTPClient/HTTPHandler.swift | 4 +- Sources/AsyncHTTPClient/RedirectState.swift | 38 ++++--------------- 4 files changed, 12 insertions(+), 42 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index 327133ec9..c644d9586 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -141,9 +141,7 @@ extension HTTPClient { from: preparedRequest.url, to: redirectURL, status: response.status, - convertToGetOn301: redirectState.convertToGetOn301, - convertToGetOn302: redirectState.convertToGetOn302, - convertToGetOn303: redirectState.convertToGetOn303 + config: redirectState.config, ) guard newRequest.body.canBeConsumedMultipleTimes else { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 9ad5c2dcd..78e8c18a8 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -125,9 +125,7 @@ extension HTTPClientRequest { from originalURL: URL, to redirectURL: URL, status: HTTPResponseStatus, - convertToGetOn301: Bool, - convertToGetOn302: Bool, - convertToGetOn303: Bool + config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration ) -> HTTPClientRequest { let (method, headers, body) = transformRequestForRedirect( from: originalURL, @@ -136,9 +134,7 @@ extension HTTPClientRequest { body: self.body, to: redirectURL, status: status, - convertToGetOn301: convertToGetOn301, - convertToGetOn302: convertToGetOn302, - convertToGetOn303: convertToGetOn303 + config: config, ) var newRequest = HTTPClientRequest(url: redirectURL.absoluteString) newRequest.method = method diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 205a7baaf..9c7becb0b 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -1077,9 +1077,7 @@ internal struct RedirectHandler { body: self.request.body, to: redirectURL, status: status, - convertToGetOn301: self.redirectState.convertToGetOn301, - convertToGetOn302: self.redirectState.convertToGetOn302, - convertToGetOn303: self.redirectState.convertToGetOn303 + config: self.redirectState.config ) let newRequest = try HTTPClient.Request( diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 07bd5d9d0..8187c3a9d 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -19,23 +19,10 @@ import struct Foundation.URL typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode struct RedirectState { - /// number of redirects we are allowed to follow. - private var limit: Int + var config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration /// All visited URLs. private var visited: [String] - - /// if true, `redirect(to:)` will throw an error if a cycle is detected. - private let allowCycles: Bool - - /// If true, POST requests are converted to GET for 301 - let convertToGetOn301: Bool - - /// If true, POST requests are converted to GET for 302 - let convertToGetOn302: Bool - - /// If true, POST requests are converted to GET for 303 - let convertToGetOn303: Bool } extension RedirectState { @@ -50,14 +37,7 @@ extension RedirectState { case .disallow: return nil case .follow(let config): - self.init( - limit: config.max, - visited: [initialURL], - allowCycles: config.allowCycles, - convertToGetOn301: config.convertToGetOn301, - convertToGetOn302: config.convertToGetOn302, - convertToGetOn303: config.convertToGetOn303 - ) + self.init(config: config, visited: [initialURL]) } } } @@ -68,11 +48,11 @@ extension RedirectState { /// - Parameter redirectURL: the new URL to redirect the request to /// - Throws: if it reaches the redirect limit or detects a redirect cycle if and `allowCycles` is false mutating func redirect(to redirectURL: String) throws { - guard self.visited.count <= limit else { + guard self.visited.count <= config.max else { throw HTTPClientError.redirectLimitReached } - guard allowCycles || !self.visited.contains(redirectURL) else { + guard config.allowCycles || !self.visited.contains(redirectURL) else { throw HTTPClientError.redirectCycleDetected } self.visited.append(redirectURL) @@ -128,17 +108,15 @@ func transformRequestForRedirect( body requestBody: Body?, to redirectURL: URL, status responseStatus: HTTPResponseStatus, - convertToGetOn301: Bool, - convertToGetOn302: Bool, - convertToGetOn303: Bool + config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration ) -> (HTTPMethod, HTTPHeaders, Body?) { let convertToGet: Bool if responseStatus == .seeOther, requestMethod != .HEAD { - convertToGet = convertToGetOn303 + convertToGet = config.convertToGetOn303 } else if responseStatus == .movedPermanently, requestMethod == .POST { - convertToGet = convertToGetOn301 + convertToGet = config.convertToGetOn301 } else if responseStatus == .found, requestMethod == .POST { - convertToGet = convertToGetOn302 + convertToGet = config.convertToGetOn302 } else { convertToGet = false } From e7c5e3dcfdca452bb12013e5b63a6cf02a38eaa2 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Tue, 17 Feb 2026 11:59:49 +0000 Subject: [PATCH 4/8] doc fix + formatting --- Sources/AsyncHTTPClient/HTTPClient.swift | 34 +++++++++++++++---- .../HTTPClientTests.swift | 4 +-- .../RequestBagTests.swift | 30 ++++++++++++++-- .../SwiftConfigurationTests.swift | 30 +++++++++++----- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 70b933481..b0108b7f6 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1295,7 +1295,13 @@ extension HTTPClient.Configuration { /// - convertToGetOn301: Whether to convert POST requests into GET requests when following a 301 redirect. /// - convertToGetOn302: Whether to convert POST requests into GET requests when following a 302 redirect. /// - convertToGetOn303: Whether to convert POST requests into GET requests when following a 303 redirect. - public init(max: Int, allowCycles: Bool, convertToGetOn301: Bool, convertToGetOn302: Bool, convertToGetOn303: Bool) { + public init( + max: Int, + allowCycles: Bool, + convertToGetOn301: Bool, + convertToGetOn302: Bool, + convertToGetOn303: Bool + ) { self.max = max self.allowCycles = allowCycles self.convertToGetOn301 = convertToGetOn301 @@ -1307,7 +1313,15 @@ extension HTTPClient.Configuration { var mode: Mode init() { - self.mode = .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)) + self.mode = .follow( + .init( + max: 5, + allowCycles: false, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ) } init(configuration: Mode) { @@ -1325,14 +1339,20 @@ extension HTTPClient.Configuration { /// /// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector. public static func follow(max: Int, allowCycles: Bool) -> RedirectConfiguration { - .follow(configuration: .init(max: max, allowCycles: allowCycles, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)) + .follow( + configuration: .init( + max: max, + allowCycles: allowCycles, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ) } - /// Redirects are followed with a specified limit. - /// - /// - parameters + /// Redirects are followed. /// - /// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector. + /// - Parameter: configuration: Configure how redirects are followed. public static func follow(configuration: FollowConfiguration) -> RedirectConfiguration { .init(configuration: .follow(configuration)) } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index c759c15ba..dc3f50e76 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4644,7 +4644,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { convertToGetOn301: true, convertToGetOn302: convertToGet, convertToGetOn303: true, - expectConvert: convertToGet + expectConvert: convertToGet ) } } @@ -4657,7 +4657,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: convertToGet, - expectConvert: convertToGet + expectConvert: convertToGet ) } } diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index e08db40c1..2ba8de64a 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -731,7 +731,15 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), + .follow( + .init( + max: 5, + allowCycles: false, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -819,7 +827,15 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), + .follow( + .init( + max: 5, + allowCycles: false, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ), initialURL: request.url.absoluteString )!, execute: { request, _ in @@ -881,7 +897,15 @@ final class RequestBagTests: XCTestCase { redirectHandler: .init( request: request, redirectState: RedirectState( - .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true)), + .follow( + .init( + max: 5, + allowCycles: false, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ), initialURL: request.url.absoluteString )!, execute: { request, _ in diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 2d09d5847..e3e382e08 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -251,7 +251,18 @@ struct HTTPClientConfigurationPropsTests { let configReader = ConfigReader(provider: testProvider) let config = try HTTPClient.Configuration(configReader: configReader) - #expect(config.redirectConfiguration.mode == .follow(.init(max: 5, allowCycles: false, convertToGetOn301: true, convertToGetOn302: true, convertToGetOn303: true))) + #expect( + config.redirectConfiguration.mode + == .follow( + .init( + max: 5, + allowCycles: false, + convertToGetOn301: true, + convertToGetOn302: true, + convertToGetOn303: true + ) + ) + ) } @Test @@ -271,15 +282,16 @@ struct HTTPClientConfigurationPropsTests { let config = try HTTPClient.Configuration(configReader: configReader) #expect( - config.redirectConfiguration.mode == .follow( - .init( - max: 3, - allowCycles: true, - convertToGetOn301: false, - convertToGetOn302: true , - convertToGetOn303: false + config.redirectConfiguration.mode + == .follow( + .init( + max: 3, + allowCycles: true, + convertToGetOn301: false, + convertToGetOn302: true, + convertToGetOn303: false + ) ) - ) ) } From 70d6337a89575fe1862b79bd7a4ee33f79fb9e03 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 20 Feb 2026 10:29:17 +0000 Subject: [PATCH 5/8] Remove convertToGetOn303 option --- Sources/AsyncHTTPClient/HTTPClient.swift | 14 +++----------- ...ientConfiguration+SwiftConfiguration.swift | 5 +---- Sources/AsyncHTTPClient/RedirectState.swift | 2 +- .../HTTPClientTests.swift | 19 +------------------ .../RequestBagTests.swift | 9 +++------ .../SwiftConfigurationTests.swift | 9 ++------- 6 files changed, 11 insertions(+), 47 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index b0108b7f6..64bc5bd35 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1284,9 +1284,6 @@ extension HTTPClient.Configuration { /// Whether to convert POST requests into GET requests when following a 302 redirect. /// This should be true as per the HTTP spec. Only change this if you know what you're doing. public var convertToGetOn302: Bool - /// Whether to convert POST requests into GET requests when following a 303 redirect. - /// This should be true as per the HTTP spec. Only change this if you know what you're doing. - public var convertToGetOn303: Bool /// Create a new ``FollowConfiguration`` /// - Parameters: @@ -1294,19 +1291,16 @@ extension HTTPClient.Configuration { /// - allowCycles: Whether cycles are allowed. /// - convertToGetOn301: Whether to convert POST requests into GET requests when following a 301 redirect. /// - convertToGetOn302: Whether to convert POST requests into GET requests when following a 302 redirect. - /// - convertToGetOn303: Whether to convert POST requests into GET requests when following a 303 redirect. public init( max: Int, allowCycles: Bool, convertToGetOn301: Bool, - convertToGetOn302: Bool, - convertToGetOn303: Bool + convertToGetOn302: Bool ) { self.max = max self.allowCycles = allowCycles self.convertToGetOn301 = convertToGetOn301 self.convertToGetOn302 = convertToGetOn302 - self.convertToGetOn303 = convertToGetOn303 } } @@ -1318,8 +1312,7 @@ extension HTTPClient.Configuration { max: 5, allowCycles: false, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ) } @@ -1344,8 +1337,7 @@ extension HTTPClient.Configuration { max: max, allowCycles: allowCycles, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ) } diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 05c300943..3565cefec 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -68,7 +68,6 @@ extension HTTPClient.Configuration.RedirectConfiguration { /// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow". /// - `convertToGetOn301` (bool, optional, default: true): Convert to GET on 301 redirect when mode is "follow". /// - `convertToGetOn302` (bool, optional, default: true): Convert to GET on 302 redirect when mode is "follow". - /// - `convertToGetOn303` (bool, optional, default: true): Convert to GET on 303 redirect when mode is "follow". /// /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid. public init(configReader: ConfigReader) throws { @@ -82,14 +81,12 @@ extension HTTPClient.Configuration.RedirectConfiguration { let allowCycles = configReader.bool(forKey: "allowCycles", default: false) let convertToGetOn301 = configReader.bool(forKey: "convertToGetOn301", default: true) let convertToGetOn302 = configReader.bool(forKey: "convertToGetOn302", default: true) - let convertToGetOn303 = configReader.bool(forKey: "convertToGetOn303", default: true) self = .follow( configuration: .init( max: maxRedirects, allowCycles: allowCycles, convertToGetOn301: convertToGetOn301, - convertToGetOn302: convertToGetOn302, - convertToGetOn303: convertToGetOn303 + convertToGetOn302: convertToGetOn302 ) ) } else if mode == "disallow" { diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 8187c3a9d..8dfd0980c 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -112,7 +112,7 @@ func transformRequestForRedirect( ) -> (HTTPMethod, HTTPHeaders, Body?) { let convertToGet: Bool if responseStatus == .seeOther, requestMethod != .HEAD { - convertToGet = config.convertToGetOn303 + convertToGet = true } else if responseStatus == .movedPermanently, requestMethod == .POST { convertToGet = config.convertToGetOn301 } else if responseStatus == .found, requestMethod == .POST { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index dc3f50e76..396559cd3 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4581,7 +4581,6 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { expectedStatus: HTTPResponseStatus, convertToGetOn301: Bool, convertToGetOn302: Bool, - convertToGetOn303: Bool, expectConvert: Bool ) throws { let bin = HTTPBin(.http1_1()) @@ -4595,8 +4594,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { max: 10, allowCycles: false, convertToGetOn301: convertToGetOn301, - convertToGetOn302: convertToGetOn302, - convertToGetOn303: convertToGetOn303 + convertToGetOn302: convertToGetOn302 ) ) ) @@ -4630,7 +4628,6 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { expectedStatus: .movedPermanently, convertToGetOn301: convertToGet, convertToGetOn302: true, - convertToGetOn303: true, expectConvert: convertToGet ) } @@ -4643,20 +4640,6 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { expectedStatus: .found, convertToGetOn301: true, convertToGetOn302: convertToGet, - convertToGetOn303: true, - expectConvert: convertToGet - ) - } - } - - func testPostConvertedToGetOn303Redirect() throws { - for convertToGet in [true, false] { - try _testPostConvertedToGetOnRedirect( - statusPath: "/redirect/303", - expectedStatus: .seeOther, - convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: convertToGet, expectConvert: convertToGet ) } diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 2ba8de64a..b356509e8 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -736,8 +736,7 @@ final class RequestBagTests: XCTestCase { max: 5, allowCycles: false, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ), initialURL: request.url.absoluteString @@ -832,8 +831,7 @@ final class RequestBagTests: XCTestCase { max: 5, allowCycles: false, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ), initialURL: request.url.absoluteString @@ -902,8 +900,7 @@ final class RequestBagTests: XCTestCase { max: 5, allowCycles: false, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ), initialURL: request.url.absoluteString diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index e3e382e08..3019f6728 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -31,7 +31,6 @@ struct HTTPClientConfigurationPropsTests { "redirect.allowCycles": true, "redirect.convertToGetOn301": false, "redirect.convertToGetOn302": false, - "redirect.convertToGetOn303": false, "timeout.connectionMs": 5000, "timeout.readMs": 30000, @@ -59,7 +58,6 @@ struct HTTPClientConfigurationPropsTests { #expect(follow.allowCycles) #expect(!follow.convertToGetOn301) #expect(!follow.convertToGetOn302) - #expect(!follow.convertToGetOn303) case .disallow: Issue.record("Unexpected value") } @@ -258,8 +256,7 @@ struct HTTPClientConfigurationPropsTests { max: 5, allowCycles: false, convertToGetOn301: true, - convertToGetOn302: true, - convertToGetOn303: true + convertToGetOn302: true ) ) ) @@ -274,7 +271,6 @@ struct HTTPClientConfigurationPropsTests { "redirect.allowCycles": true, "redirect.convertToGetOn301": false, "redirect.convertToGetOn302": true, - "redirect.convertToGetOn303": false, ]) let configReader = ConfigReader(provider: testProvider) @@ -288,8 +284,7 @@ struct HTTPClientConfigurationPropsTests { max: 3, allowCycles: true, convertToGetOn301: false, - convertToGetOn302: true, - convertToGetOn303: false + convertToGetOn302: true ) ) ) From 54d251d2ae30bf775f223b451b18225e1f2e563a Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 20 Feb 2026 10:54:56 +0000 Subject: [PATCH 6/8] Rename --- Sources/AsyncHTTPClient/HTTPClient.swift | 32 +++++++++---------- ...ientConfiguration+SwiftConfiguration.swift | 12 +++---- Sources/AsyncHTTPClient/RedirectState.swift | 4 +-- .../HTTPClientTests.swift | 32 +++++++++---------- .../RequestBagTests.swift | 12 +++---- .../SwiftConfigurationTests.swift | 20 ++++++------ 6 files changed, 56 insertions(+), 56 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 64bc5bd35..31630a472 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1278,29 +1278,29 @@ extension HTTPClient.Configuration { public var max: Int /// Whether cycles are allowed. public var allowCycles: Bool - /// Whether to convert POST requests into GET requests when following a 301 redirect. - /// This should be true as per the HTTP spec. Only change this if you know what you're doing. - public var convertToGetOn301: Bool - /// Whether to convert POST requests into GET requests when following a 302 redirect. - /// This should be true as per the HTTP spec. Only change this if you know what you're doing. - public var convertToGetOn302: Bool + /// Whether to retain the HTTP method and body when following a 301 redirect. + /// This should be false as per the fetch spec, but may be true according to RFC 9110. + public var retainHTTPMethodAndBodyOn301: Bool + /// Whether to retain the HTTP method and body when following a 302 redirect. + /// This should be false as per the fetch spec, but may be true according to RFC 9110. + public var retainHTTPMethodAndBodyOn302: Bool /// Create a new ``FollowConfiguration`` /// - Parameters: /// - max: The maximum number of allowed redirects. /// - allowCycles: Whether cycles are allowed. - /// - convertToGetOn301: Whether to convert POST requests into GET requests when following a 301 redirect. - /// - convertToGetOn302: Whether to convert POST requests into GET requests when following a 302 redirect. + /// - retainHTTPMethodAndBodyOn301: Whether to retain the HTTP method and body when following a 301 redirect. This should be false as per the fetch spec, but may be true according to RFC 9110. + /// - retainHTTPMethodAndBodyOn302: Whether to retain the HTTP method and body when following a 302 redirect. This should be false as per the fetch spec, but may be true according to RFC 9110. public init( max: Int, allowCycles: Bool, - convertToGetOn301: Bool, - convertToGetOn302: Bool + retainHTTPMethodAndBodyOn301: Bool, + retainHTTPMethodAndBodyOn302: Bool ) { self.max = max self.allowCycles = allowCycles - self.convertToGetOn301 = convertToGetOn301 - self.convertToGetOn302 = convertToGetOn302 + self.retainHTTPMethodAndBodyOn301 = retainHTTPMethodAndBodyOn301 + self.retainHTTPMethodAndBodyOn302 = retainHTTPMethodAndBodyOn302 } } @@ -1311,8 +1311,8 @@ extension HTTPClient.Configuration { .init( max: 5, allowCycles: false, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ) } @@ -1336,8 +1336,8 @@ extension HTTPClient.Configuration { configuration: .init( max: max, allowCycles: allowCycles, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ) } diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 3565cefec..686945c66 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -66,8 +66,8 @@ extension HTTPClient.Configuration.RedirectConfiguration { /// - `mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). /// - `maxRedirects` (int, optional, default: 5): Maximum allowed redirects when mode is "follow". /// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow". - /// - `convertToGetOn301` (bool, optional, default: true): Convert to GET on 301 redirect when mode is "follow". - /// - `convertToGetOn302` (bool, optional, default: true): Convert to GET on 302 redirect when mode is "follow". + /// - `retainHTTPMethodAndBodyOn301` (bool, optional, default: false): Retain the original HTTP method and body on 301 redirect when mode is "follow". This is contrary to the fetch specification, but is allowed by RFC 9110. + /// - `retainHTTPMethodAndBodyOn302` (bool, optional, default: false): Retain the original HTTP method and body on 302 redirect when mode is "follow". This is contrary to the fetch specification, but is allowed by RFC 9110. /// /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid. public init(configReader: ConfigReader) throws { @@ -79,14 +79,14 @@ extension HTTPClient.Configuration.RedirectConfiguration { if mode == "follow" { let maxRedirects = configReader.int(forKey: "maxRedirects", default: 5) let allowCycles = configReader.bool(forKey: "allowCycles", default: false) - let convertToGetOn301 = configReader.bool(forKey: "convertToGetOn301", default: true) - let convertToGetOn302 = configReader.bool(forKey: "convertToGetOn302", default: true) + let retainHTTPMethodAndBodyOn301 = configReader.bool(forKey: "retainHTTPMethodAndBodyOn301", default: false) + let retainHTTPMethodAndBodyOn302 = configReader.bool(forKey: "retainHTTPMethodAndBodyOn302", default: false) self = .follow( configuration: .init( max: maxRedirects, allowCycles: allowCycles, - convertToGetOn301: convertToGetOn301, - convertToGetOn302: convertToGetOn302 + retainHTTPMethodAndBodyOn301: retainHTTPMethodAndBodyOn301, + retainHTTPMethodAndBodyOn302: retainHTTPMethodAndBodyOn302 ) ) } else if mode == "disallow" { diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 8dfd0980c..4e14712d6 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -114,9 +114,9 @@ func transformRequestForRedirect( if responseStatus == .seeOther, requestMethod != .HEAD { convertToGet = true } else if responseStatus == .movedPermanently, requestMethod == .POST { - convertToGet = config.convertToGetOn301 + convertToGet = !config.retainHTTPMethodAndBodyOn301 } else if responseStatus == .found, requestMethod == .POST { - convertToGet = config.convertToGetOn302 + convertToGet = !config.retainHTTPMethodAndBodyOn302 } else { convertToGet = false } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 396559cd3..959e0f939 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4579,9 +4579,9 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { private func _testPostConvertedToGetOnRedirect( statusPath: String, expectedStatus: HTTPResponseStatus, - convertToGetOn301: Bool, - convertToGetOn302: Bool, - expectConvert: Bool + retainHTTPMethodAndBodyOn301: Bool, + retainHTTPMethodAndBodyOn302: Bool, + expectRetain: Bool ) throws { let bin = HTTPBin(.http1_1()) defer { XCTAssertNoThrow(try bin.shutdown()) } @@ -4593,8 +4593,8 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { configuration: .init( max: 10, allowCycles: false, - convertToGetOn301: convertToGetOn301, - convertToGetOn302: convertToGetOn302 + retainHTTPMethodAndBodyOn301: retainHTTPMethodAndBodyOn301, + retainHTTPMethodAndBodyOn302: retainHTTPMethodAndBodyOn302 ) ) ) @@ -4613,34 +4613,34 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return XCTFail("Expected 2 entries in history for \(statusPath)") } XCTAssertEqual(response.history[0].request.method, .POST) - if expectConvert { - XCTAssertEqual(response.history[1].request.method, .GET) - } else { + if expectRetain { XCTAssertEqual(response.history[1].request.method, .POST) + } else { + XCTAssertEqual(response.history[1].request.method, .GET) } XCTAssertEqual(response.history[0].responseHead.status, expectedStatus) } func testPostConvertedToGetOn301Redirect() throws { - for convertToGet in [true, false] { + for retainHTTPMethodAndBody in [true, false] { try _testPostConvertedToGetOnRedirect( statusPath: "/redirect/301", expectedStatus: .movedPermanently, - convertToGetOn301: convertToGet, - convertToGetOn302: true, - expectConvert: convertToGet + retainHTTPMethodAndBodyOn301: retainHTTPMethodAndBody, + retainHTTPMethodAndBodyOn302: false, + expectRetain: retainHTTPMethodAndBody ) } } func testPostConvertedToGetOn302Redirect() throws { - for convertToGet in [true, false] { + for retainHTTPMethodAndBody in [true, false] { try _testPostConvertedToGetOnRedirect( statusPath: "/redirect/302", expectedStatus: .found, - convertToGetOn301: true, - convertToGetOn302: convertToGet, - expectConvert: convertToGet + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: retainHTTPMethodAndBody, + expectRetain: retainHTTPMethodAndBody ) } } diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index b356509e8..a3ae5017c 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -735,8 +735,8 @@ final class RequestBagTests: XCTestCase { .init( max: 5, allowCycles: false, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ), initialURL: request.url.absoluteString @@ -830,8 +830,8 @@ final class RequestBagTests: XCTestCase { .init( max: 5, allowCycles: false, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ), initialURL: request.url.absoluteString @@ -899,8 +899,8 @@ final class RequestBagTests: XCTestCase { .init( max: 5, allowCycles: false, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ), initialURL: request.url.absoluteString diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 3019f6728..6bb796f6c 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -29,8 +29,8 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 10, "redirect.allowCycles": true, - "redirect.convertToGetOn301": false, - "redirect.convertToGetOn302": false, + "redirect.retainHTTPMethodAndBodyOn301": true, + "redirect.retainHTTPMethodAndBodyOn302": true, "timeout.connectionMs": 5000, "timeout.readMs": 30000, @@ -56,8 +56,8 @@ struct HTTPClientConfigurationPropsTests { case .follow(let follow): #expect(follow.max == 10) #expect(follow.allowCycles) - #expect(!follow.convertToGetOn301) - #expect(!follow.convertToGetOn302) + #expect(follow.retainHTTPMethodAndBodyOn301) + #expect(follow.retainHTTPMethodAndBodyOn302) case .disallow: Issue.record("Unexpected value") } @@ -255,8 +255,8 @@ struct HTTPClientConfigurationPropsTests { .init( max: 5, allowCycles: false, - convertToGetOn301: true, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false ) ) ) @@ -269,8 +269,8 @@ struct HTTPClientConfigurationPropsTests { "redirect.mode": "follow", "redirect.maxRedirects": 3, "redirect.allowCycles": true, - "redirect.convertToGetOn301": false, - "redirect.convertToGetOn302": true, + "redirect.retainHTTPMethodAndBodyOn301": true, + "redirect.retainHTTPMethodAndBodyOn302": false, ]) let configReader = ConfigReader(provider: testProvider) @@ -283,8 +283,8 @@ struct HTTPClientConfigurationPropsTests { .init( max: 3, allowCycles: true, - convertToGetOn301: false, - convertToGetOn302: true + retainHTTPMethodAndBodyOn301: true, + retainHTTPMethodAndBodyOn302: false ) ) ) From f6ae022d6254b6c7ee09e3c06b20360a9f62aaf8 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 20 Feb 2026 11:07:16 +0000 Subject: [PATCH 7/8] Better docs --- Sources/AsyncHTTPClient/HTTPClient.swift | 10 ++++++---- .../HTTPClientConfiguration+SwiftConfiguration.swift | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 31630a472..1aa37fb7e 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1278,19 +1278,21 @@ extension HTTPClient.Configuration { public var max: Int /// Whether cycles are allowed. public var allowCycles: Bool - /// Whether to retain the HTTP method and body when following a 301 redirect. + /// Whether to retain the HTTP method and body when following a 301 redirect on a POST request. /// This should be false as per the fetch spec, but may be true according to RFC 9110. + /// This does not affect non-POST requests. public var retainHTTPMethodAndBodyOn301: Bool - /// Whether to retain the HTTP method and body when following a 302 redirect. + /// Whether to retain the HTTP method and body when following a 302 redirect on a POST request. /// This should be false as per the fetch spec, but may be true according to RFC 9110. + /// This does not affect non-POST requests. public var retainHTTPMethodAndBodyOn302: Bool /// Create a new ``FollowConfiguration`` /// - Parameters: /// - max: The maximum number of allowed redirects. /// - allowCycles: Whether cycles are allowed. - /// - retainHTTPMethodAndBodyOn301: Whether to retain the HTTP method and body when following a 301 redirect. This should be false as per the fetch spec, but may be true according to RFC 9110. - /// - retainHTTPMethodAndBodyOn302: Whether to retain the HTTP method and body when following a 302 redirect. This should be false as per the fetch spec, but may be true according to RFC 9110. + /// - retainHTTPMethodAndBodyOn301: Whether to retain the HTTP method and body when following a 301 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests. + /// - retainHTTPMethodAndBodyOn302: Whether to retain the HTTP method and body when following a 302 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests. public init( max: Int, allowCycles: Bool, diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 686945c66..3015dfadd 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -66,8 +66,8 @@ extension HTTPClient.Configuration.RedirectConfiguration { /// - `mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). /// - `maxRedirects` (int, optional, default: 5): Maximum allowed redirects when mode is "follow". /// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow". - /// - `retainHTTPMethodAndBodyOn301` (bool, optional, default: false): Retain the original HTTP method and body on 301 redirect when mode is "follow". This is contrary to the fetch specification, but is allowed by RFC 9110. - /// - `retainHTTPMethodAndBodyOn302` (bool, optional, default: false): Retain the original HTTP method and body on 302 redirect when mode is "follow". This is contrary to the fetch specification, but is allowed by RFC 9110. + /// - `retainHTTPMethodAndBodyOn301` (bool, optional, default: false): Whether to retain the HTTP method and body when following a 301 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests. + /// - `retainHTTPMethodAndBodyOn302` (bool, optional, default: false): Whether to retain the HTTP method and body when following a 302 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests. /// /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid. public init(configReader: ConfigReader) throws { From edbfcdbfc01c0ebb4455bff6ad20c659fab08c6b Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 20 Feb 2026 11:38:08 +0000 Subject: [PATCH 8/8] Remove trailing commas --- Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift | 2 +- .../AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index c644d9586..b77cb9527 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -141,7 +141,7 @@ extension HTTPClient { from: preparedRequest.url, to: redirectURL, status: response.status, - config: redirectState.config, + config: redirectState.config ) guard newRequest.body.canBeConsumedMultipleTimes else { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 78e8c18a8..03cd8e464 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -134,7 +134,7 @@ extension HTTPClientRequest { body: self.body, to: redirectURL, status: status, - config: config, + config: config ) var newRequest = HTTPClientRequest(url: redirectURL.absoluteString) newRequest.method = method