Skip to content

Commit 5e0a276

Browse files
committed
Support multiple informational responses
Motivation: Per [RFC 9110 §15.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.2) and [RFC 9114 §4.1](https://www.rfc-editor.org/rfc/rfc9114.html#section-4.1), servers may send zero or more informational responses (1xx status code) prior to the final response, and clients MUST be able to parse one or more informational responses prior to the final response. There are two bugs in our current implementation: - `HTTP3FrameValidator.RequestStreamValidator` currently only allows sending/receiving multiple `100 Continue` informational responses. All other 1xx informational responses can only be sent/received once. - `HTTPMessageParsingStateMachine` does not handle multiple informational responses. Every HEADERS frame is treated as the final response head. Modifications: - Updated `HTTP3FrameValidator`'s `RequestStreamValidator` and `HTTPMessageParsingStateMachine` to allow multiple informational responses with any 1xx status code to be sent/received. - Added associated tests. Result: Servers can now send, and clients can now parse, any number of 1xx informational responses (with any 1xx status code) preceding the final response.
1 parent 3a03b4e commit 5e0a276

5 files changed

Lines changed: 160 additions & 8 deletions

File tree

Sources/HTTP3/HTTP3Frame.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,21 @@ extension HTTP3PartialFrame {
302302
}
303303
}
304304

305+
extension HTTP3Frame.Headers {
306+
/// Whether these headers contain a `:status` pseudo-header field whose value is an informational 1xx status code.
307+
///
308+
/// Returns `false` if the `:status` pseudo-header field is not present or if its value does not represent a 1xx
309+
/// status code.
310+
///
311+
/// - SeeAlso: https://www.rfc-editor.org/rfc/rfc9110.html#section-15.2.
312+
package var representsInterimResponse: Bool {
313+
guard let status = self.fields.first(where: { $0.name == .status })?.value, let code = Int(status) else {
314+
return false
315+
}
316+
return (100..<200).contains(code)
317+
}
318+
}
319+
305320
extension HTTP3Frame {
306321
package static func data(_ payload: ByteBuffer) -> Self {
307322
.data(.init(payload: payload))

Sources/HTTP3/HTTP3FrameValidator.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,7 @@ package enum HTTP3FrameValidator: ~Copyable {
228228
case .headers(let headers):
229229
// A response MAY consist of multiple messages when and only when one or more interim responses (1xx; see Section 15.2 of [HTTP]) precede a final response to the same request.
230230
// Interim responses do not contain content or trailer sections.
231-
let status = headers.fields.first(where: { $0.name == .status })?.value
232-
if status == "100" {
231+
if headers.representsInterimResponse {
233232
// This header doesn't affect our state because it's interim. We just pass it through
234233
self = .init(requestState: existingRequestState, responseState: existingResponseState)
235234
return .forwardFrame(frame)

Sources/NIOHTTP3/HTTP3ToHTTPCodecs.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,13 @@ extension HTTPResponsePart: HTTPMessagePart {
196196
/// Use this to convert incoming frames into message parts.
197197
package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
198198
enum State {
199-
case idle
199+
case awaitingFinalHead
200200
case processedHeaders
201201
case processedTrailers
202202
case previousError
203203
}
204204

205-
private var state = State.idle
205+
private var state = State.awaitingFinalHead
206206

207207
package init() {}
208208

@@ -215,12 +215,19 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
215215
switch self.state {
216216
case .previousError:
217217
return .none
218-
case .idle:
218+
case .awaitingFinalHead:
219219
switch frame {
220220
case .headers(let headers):
221221
do {
222222
let part = try Part.head(fields: headers.fields)
223-
self.state = .processedHeaders
223+
if headers.representsInterimResponse {
224+
// Multiple interim (1xx) responses can precede the final response; remain in the same state to
225+
// accept further interim responses or the final response. We can only reach this branch on the
226+
// response parsing side.
227+
self.state = .awaitingFinalHead
228+
} else {
229+
self.state = .processedHeaders
230+
}
224231
return .returnPart(part)
225232
} catch {
226233
self.state = .previousError
@@ -261,7 +268,7 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
261268

262269
package mutating func inputClosed() -> InputClosedAction? {
263270
switch self.state {
264-
case .idle:
271+
case .awaitingFinalHead:
265272
self.state = .processedTrailers
266273
return .returnPart(.end())
267274
case .processedHeaders:

Tests/HTTP3Tests/HTTP3FrameValidatorTests.swift

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,19 +306,58 @@ struct HTTP3FrameValidatorTests {
306306
}
307307

308308
@Test
309-
func testDoubleResponse() {
309+
func testInboundInterimResponse() {
310+
var validator = HTTP3FrameValidator(streamType: .request, incoming: false)
311+
validator.assertOutboundFramePassesThrough(Self.validRequestHeaders())
312+
313+
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
314+
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
315+
validator.assertInboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
316+
validator.assertInboundFramePassesThrough(Self.validTrailers())
317+
}
318+
319+
@Test
320+
func testInboundMultipleInterimResponse() {
310321
var validator = HTTP3FrameValidator(streamType: .request, incoming: false)
311322
validator.assertOutboundFramePassesThrough(Self.validRequestHeaders()) // write req headers
312323

313324
// Some informational responses
314325
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
315326
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
327+
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "102")]))
328+
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
316329
// Final response
317330
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
318331
validator.assertInboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
319332
validator.assertInboundFramePassesThrough(.headers([.init(name: .cookie, value: "test")]))
320333
}
321334

335+
@Test
336+
func testOutboundInterimResponse() {
337+
var validator = HTTP3FrameValidator(streamType: .request, incoming: true)
338+
validator.assertInboundFramePassesThrough(Self.validRequestHeaders())
339+
340+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
341+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
342+
validator.assertOutboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
343+
}
344+
345+
@Test
346+
func testOutboundMultipleInterimResponses() {
347+
var validator = HTTP3FrameValidator(streamType: .request, incoming: true)
348+
validator.assertInboundFramePassesThrough(Self.validRequestHeaders())
349+
350+
// Some informational responses
351+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
352+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
353+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "102")]))
354+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
355+
// Final response
356+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
357+
validator.assertOutboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
358+
validator.assertOutboundFramePassesThrough(.headers([.init(name: .cookie, value: "test")]))
359+
}
360+
322361
// MARK: Control stream
323362

324363
/// Anything other than settings is invalid to be the first frame.

Tests/NIOHTTP3Tests/HTTPMessageParsingTests.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,70 @@ struct HTTPMessageParsingTests {
153153
#expect(action3 == nil)
154154
}
155155

156+
let informationalEarlyHintsHead = HTTP3Frame.headers([.init(name: .status, value: "103")])
157+
158+
@Test
159+
func testSingleInterimThenFinal() throws {
160+
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()
161+
162+
let action1 = machine.processFrame(frame: self.informationalEarlyHintsHead)
163+
#expect(action1?.returnPart == .head(.init(status: .init(code: 103))))
164+
165+
let action2 = machine.processFrame(frame: self.validResponseHead)
166+
#expect(action2?.returnPart == .head(.init(status: .ok)))
167+
168+
let action3 = machine.processFrame(frame: .data(.init()))
169+
#expect(action3?.returnPart == .body(.init()))
170+
171+
let action4 = machine.processFrame(frame: self.validTrailers)
172+
#expect(action4?.returnPart == .end([.init("test")!: "hello"]))
173+
}
174+
175+
@Test
176+
func testMultipleInterimThenFinal() throws {
177+
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()
178+
179+
let action1 = machine.processFrame(frame: self.informationalEarlyHintsHead)
180+
#expect(action1?.returnPart == .head(.init(status: .init(code: 103))))
181+
182+
let action2 = machine.processFrame(frame: self.informationalEarlyHintsHead)
183+
#expect(action2?.returnPart == .head(.init(status: .init(code: 103))))
184+
185+
let action3 = machine.processFrame(frame: self.validResponseHead)
186+
#expect(action3?.returnPart == .head(.init(status: .ok)))
187+
188+
let action4 = machine.processFrame(frame: .data(.init()))
189+
#expect(action4?.returnPart == .body(.init()))
190+
191+
let action5 = machine.processFrame(frame: self.validTrailers)
192+
#expect(action5?.returnPart == .end([.init("test")!: "hello"]))
193+
}
194+
195+
@Test
196+
func testInvalidInterimHeaders() throws {
197+
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()
198+
199+
// 1xx response with a forbidden transfer-encoding field.
200+
let action1 = machine.processFrame(
201+
frame: .headers([
202+
.init(name: .status, value: "103"),
203+
.init(name: .transferEncoding, value: "chunked"),
204+
])
205+
)
206+
action1.assertError { error in
207+
expectH3ErrorEqual(
208+
error: error,
209+
expectedCode: .malformedMessage,
210+
expectedH3ErrorCode: .H3_MESSAGE_ERROR,
211+
expectedMessage: "Invalid headers"
212+
)
213+
}
214+
215+
// After error, even valid final headers are ignored.
216+
let action2 = machine.processFrame(frame: self.validResponseHead)
217+
#expect(action2 == nil)
218+
}
219+
156220
// MARK: General request and response header validation
157221

158222
@Test
@@ -626,3 +690,31 @@ extension HTTPMessageParsingStateMachine<HTTPResponsePart>.ProcessFrameAction? {
626690
}
627691
}
628692
}
693+
694+
extension HTTPMessageParsingStateMachine.InputClosedAction {
695+
fileprivate func assertIncomplete(sourceLocation: SourceLocation = #_sourceLocation) {
696+
switch self {
697+
case .messageIncomplete:
698+
break
699+
default: Issue.record("Expected .messageIncomplete, got \(self)", sourceLocation: sourceLocation)
700+
}
701+
}
702+
}
703+
704+
extension HTTPMessageParsingStateMachine<HTTPRequestPart>.InputClosedAction? {
705+
fileprivate func assertIncomplete(sourceLocation: SourceLocation = #_sourceLocation) {
706+
switch self {
707+
case .some(let action): action.assertIncomplete(sourceLocation: sourceLocation)
708+
case .none: Issue.record("Expected .messageIncomplete, got no action", sourceLocation: sourceLocation)
709+
}
710+
}
711+
}
712+
713+
extension HTTPMessageParsingStateMachine<HTTPResponsePart>.InputClosedAction? {
714+
fileprivate func assertIncomplete(sourceLocation: SourceLocation = #_sourceLocation) {
715+
switch self {
716+
case .some(let action): action.assertIncomplete(sourceLocation: sourceLocation)
717+
case .none: Issue.record("Expected .messageIncomplete, got no action", sourceLocation: sourceLocation)
718+
}
719+
}
720+
}

0 commit comments

Comments
 (0)