Skip to content

Commit 170fd53

Browse files
authored
Support informational response heads (#469)
1 parent cc8e7a6 commit 170fd53

10 files changed

+179
-30
lines changed

Package.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ let package = Package(
2121
.library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]),
2222
],
2323
dependencies: [
24-
.package(url: "https://github.com/apple/swift-nio.git", from: "2.32.0"),
24+
.package(url: "https://github.com/apple/swift-nio.git", from: "2.34.0"),
2525
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.14.1"),
2626
.package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.18.2"),
2727
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.10.0"),

Sources/AsyncHTTPClient/ConnectionPool/HTTP1.1/HTTP1Connection.swift

+10-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,16 @@ final class HTTP1Connection {
116116

117117
do {
118118
let sync = self.channel.pipeline.syncOperations
119-
try sync.addHTTPClientHandlers()
119+
120+
// We can not use `sync.addHTTPClientHandlers()`, as we want to explicitly set the
121+
// `.informationalResponseStrategy` for the decoder.
122+
let requestEncoder = HTTPRequestEncoder()
123+
let responseDecoder = HTTPResponseDecoder(
124+
leftOverBytesStrategy: .dropBytes,
125+
informationalResponseStrategy: .forward
126+
)
127+
try sync.addHandler(requestEncoder)
128+
try sync.addHandler(ByteToMessageHandler(responseDecoder))
120129

121130
if case .enabled(let limit) = configuration.decompression {
122131
let decompressHandler = NIOHTTPResponseDecompressor(limit: limit)

Sources/AsyncHTTPClient/ConnectionPool/HTTPRequestStateMachine.swift

+1-7
Original file line numberDiff line numberDiff line change
@@ -529,13 +529,7 @@ struct HTTPRequestStateMachine {
529529
preconditionFailure("How can we receive a response head before sending a request head ourselves. Invalid state: \(self.state)")
530530

531531
case .running(_, .waitingForHead):
532-
// If we receive a http response header with a status code of 1xx, we ignore the header
533-
// except for 101, which we consume.
534-
// If the remote closes the connection after sending a 1xx (not 101) response head, we
535-
// will receive a response end from the parser. We need to protect against this case.
536-
let error = HTTPClientError.httpEndReceivedAfterHeadWith1xx
537-
self.state = .failed(error)
538-
return .failRequest(error, .close)
532+
preconditionFailure("How can we receive a response end, if we haven't a received a head. Invalid state: \(self.state)")
539533

540534
case .running(.streaming(let expectedBodyLength, let sentBodyBytes, let producerState), .receivingBody(let head, var responseStreamState))
541535
where head.status.code < 300:

Sources/AsyncHTTPClient/HTTPClient.swift

+1
Original file line numberDiff line numberDiff line change
@@ -996,5 +996,6 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
996996
/// - Tasks are not processed fast enough on the existing connections, to process all waiters in time
997997
public static let getConnectionFromPoolTimeout = HTTPClientError(code: .getConnectionFromPoolTimeout)
998998

999+
@available(*, deprecated, message: "AsyncHTTPClient now correctly supports informational headers. For this reason `httpEndReceivedAfterHeadWith1xx` will not be thrown anymore.")
9991000
public static let httpEndReceivedAfterHeadWith1xx = HTTPClientError(code: .httpEndReceivedAfterHeadWith1xx)
10001001
}

Tests/AsyncHTTPClientTests/HTTP1ConnectionStateMachineTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ class HTTP1ConnectionStateMachineTests: XCTestCase {
265265
XCTAssertEqual(newRequestAction, .sendRequestHead(requestHead, startBody: false))
266266
let responseHead = HTTPResponseHead(version: .http1_1, status: .init(statusCode: 103, reasonPhrase: "Early Hints"))
267267
XCTAssertEqual(state.channelRead(.head(responseHead)), .wait)
268-
XCTAssertEqual(state.channelRead(.end(nil)), .failRequest(HTTPClientError.httpEndReceivedAfterHeadWith1xx, .close))
268+
XCTAssertEqual(state.channelInactive(), .failRequest(HTTPClientError.remoteConnectionClosed, .none))
269269
}
270270
}
271271

Tests/AsyncHTTPClientTests/HTTP1ConnectionTests+XCTest.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ extension HTTP1ConnectionTests {
3333
("testConnectionClosesOnRandomlyAppearingCloseHeader", testConnectionClosesOnRandomlyAppearingCloseHeader),
3434
("testConnectionClosesAfterTheRequestWithoutHavingSentAnCloseHeader", testConnectionClosesAfterTheRequestWithoutHavingSentAnCloseHeader),
3535
("testConnectionIsClosedAfterSwitchingProtocols", testConnectionIsClosedAfterSwitchingProtocols),
36-
("testConnectionDoesntCrashAfterConnectionCloseAndEarlyHints", testConnectionDoesntCrashAfterConnectionCloseAndEarlyHints),
36+
("testConnectionDropAfterEarlyHints", testConnectionDropAfterEarlyHints),
3737
("testConnectionIsClosedIfResponseIsReceivedBeforeRequest", testConnectionIsClosedIfResponseIsReceivedBeforeRequest),
3838
("testDoubleHTTPResponseLine", testDoubleHTTPResponseLine),
3939
("testDownloadStreamingBackpressure", testDownloadStreamingBackpressure),

Tests/AsyncHTTPClientTests/HTTP1ConnectionTests.swift

+9-19
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ class HTTP1ConnectionTests: XCTestCase {
430430
XCTAssertEqual(response?.body, nil)
431431
}
432432

433-
func testConnectionDoesntCrashAfterConnectionCloseAndEarlyHints() {
433+
func testConnectionDropAfterEarlyHints() {
434434
let embedded = EmbeddedChannel()
435435
let logger = Logger(label: "test.http1.connection")
436436

@@ -481,25 +481,15 @@ class HTTP1ConnectionTests: XCTestCase {
481481
XCTAssertEqual(connectionDelegate.hitConnectionReleased, 0)
482482
XCTAssertNoThrow(try embedded.writeInbound(ByteBuffer(string: responseString)))
483483

484-
if !embedded.isActive {
485-
// behavior before https://github.com/apple/swift-nio/pull/1984
486-
embedded.embeddedEventLoop.run() // tick once to run futures.
487-
XCTAssertEqual(connectionDelegate.hitConnectionClosed, 1)
488-
XCTAssertEqual(connectionDelegate.hitConnectionReleased, 0)
484+
XCTAssertTrue(embedded.isActive, "The connection remains active after the informational response head")
485+
XCTAssertNoThrow(try embedded.close().wait(), "the connection was closed")
489486

490-
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
491-
XCTAssertEqual($0 as? HTTPClientError, .httpEndReceivedAfterHeadWith1xx)
492-
}
493-
} else {
494-
// behavior after https://github.com/apple/swift-nio/pull/1984
495-
XCTAssertNoThrow(try embedded.close().wait())
496-
embedded.embeddedEventLoop.run() // tick once to run futures.
497-
XCTAssertEqual(connectionDelegate.hitConnectionClosed, 1)
498-
XCTAssertEqual(connectionDelegate.hitConnectionReleased, 0)
499-
500-
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
501-
XCTAssertEqual($0 as? HTTPClientError, .remoteConnectionClosed)
502-
}
487+
embedded.embeddedEventLoop.run() // tick once to run futures.
488+
XCTAssertEqual(connectionDelegate.hitConnectionClosed, 1)
489+
XCTAssertEqual(connectionDelegate.hitConnectionReleased, 0)
490+
491+
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
492+
XCTAssertEqual($0 as? HTTPClientError, .remoteConnectionClosed)
503493
}
504494
}
505495

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
//
15+
// HTTPClientInformationalResponsesTests+XCTest.swift
16+
//
17+
import XCTest
18+
19+
///
20+
/// NOTE: This file was generated by generate_linux_tests.rb
21+
///
22+
/// Do NOT edit this file directly as it will be regenerated automatically when needed.
23+
///
24+
25+
extension HTTPClientReproTests {
26+
static var allTests: [(String, (HTTPClientReproTests) -> () throws -> Void)] {
27+
return [
28+
("testServerSends100ContinueFirst", testServerSends100ContinueFirst),
29+
("testServerSendsSwitchingProtocols", testServerSendsSwitchingProtocols),
30+
]
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import AsyncHTTPClient
16+
import Logging
17+
import NIOCore
18+
import NIOHTTP1
19+
import XCTest
20+
21+
final class HTTPClientReproTests: XCTestCase {
22+
func testServerSends100ContinueFirst() {
23+
final class HTTPInformationalResponseHandler: ChannelInboundHandler {
24+
typealias InboundIn = HTTPServerRequestPart
25+
typealias OutboundOut = HTTPServerResponsePart
26+
27+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
28+
switch self.unwrapInboundIn(data) {
29+
case .head:
30+
context.writeAndFlush(self.wrapOutboundOut(.head(.init(version: .http1_1, status: .continue))), promise: nil)
31+
case .body:
32+
break
33+
case .end:
34+
context.write(self.wrapOutboundOut(.head(.init(version: .http1_1, status: .ok))), promise: nil)
35+
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
36+
}
37+
}
38+
}
39+
40+
let client = HTTPClient(eventLoopGroupProvider: .createNew)
41+
defer { XCTAssertNoThrow(try client.syncShutdown()) }
42+
43+
let httpBin = HTTPBin(.http1_1(ssl: false, compress: false)) { _ in
44+
HTTPInformationalResponseHandler()
45+
}
46+
47+
let body = #"{"foo": "bar"}"#
48+
49+
var maybeRequest: HTTPClient.Request?
50+
XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(
51+
url: "http://localhost:\(httpBin.port)/",
52+
method: .POST,
53+
headers: [
54+
"Content-Type": "application/json",
55+
],
56+
body: .string(body)
57+
))
58+
guard let request = maybeRequest else { return XCTFail("Expected to have a request here") }
59+
60+
var logger = Logger(label: "test")
61+
logger.logLevel = .trace
62+
63+
var response: HTTPClient.Response?
64+
XCTAssertNoThrow(response = try client.execute(request: request, logger: logger).wait())
65+
XCTAssertEqual(response?.status, .ok)
66+
}
67+
68+
func testServerSendsSwitchingProtocols() {
69+
final class HTTPInformationalResponseHandler: ChannelInboundHandler {
70+
typealias InboundIn = HTTPServerRequestPart
71+
typealias OutboundOut = HTTPServerResponsePart
72+
73+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
74+
switch self.unwrapInboundIn(data) {
75+
case .head:
76+
let head = HTTPResponseHead(version: .http1_1, status: .switchingProtocols, headers: [
77+
"Connection": "Upgrade",
78+
"Upgrade": "Websocket",
79+
])
80+
let body = context.channel.allocator.buffer(string: "foo bar")
81+
82+
context.write(self.wrapOutboundOut(.head(head)), promise: nil)
83+
context.write(self.wrapOutboundOut(.body(.byteBuffer(body))), promise: nil)
84+
// we purposefully don't send an `.end` here.
85+
context.flush()
86+
case .body:
87+
break
88+
case .end:
89+
break
90+
}
91+
}
92+
}
93+
94+
let client = HTTPClient(eventLoopGroupProvider: .createNew)
95+
defer { XCTAssertNoThrow(try client.syncShutdown()) }
96+
97+
let httpBin = HTTPBin(.http1_1(ssl: false, compress: false)) { _ in
98+
HTTPInformationalResponseHandler()
99+
}
100+
101+
let body = #"{"foo": "bar"}"#
102+
103+
var maybeRequest: HTTPClient.Request?
104+
XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(
105+
url: "http://localhost:\(httpBin.port)/",
106+
method: .POST,
107+
headers: [
108+
"Content-Type": "application/json",
109+
],
110+
body: .string(body)
111+
))
112+
guard let request = maybeRequest else { return XCTFail("Expected to have a request here") }
113+
114+
var logger = Logger(label: "test")
115+
logger.logLevel = .trace
116+
117+
var response: HTTPClient.Response?
118+
XCTAssertNoThrow(response = try client.execute(request: request, logger: logger).wait())
119+
XCTAssertEqual(response?.status, .switchingProtocols)
120+
XCTAssertNil(response?.body)
121+
}
122+
}

Tests/LinuxMain.swift

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import XCTest
3737
testCase(HTTPClientCookieTests.allTests),
3838
testCase(HTTPClientInternalTests.allTests),
3939
testCase(HTTPClientNIOTSTests.allTests),
40+
testCase(HTTPClientReproTests.allTests),
4041
testCase(HTTPClientSOCKSTests.allTests),
4142
testCase(HTTPClientTests.allTests),
4243
testCase(HTTPConnectionPoolTests.allTests),

0 commit comments

Comments
 (0)