Skip to content

Commit 54e5fc5

Browse files
authored
Support multiple interim responses (#6)
### 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 interim responses (1xx status code) prior to the final response, and clients MUST be able to parse one or more interim responses prior to the final response. There are two bugs in our current implementation: - `HTTP3FrameValidator.RequestStreamValidator` currently only allows sending/receiving multiple `100 Continue` interim responses. All other 1xx interim responses can only be sent/received once. - `HTTPMessageParsingStateMachine` does not handle multiple interim responses. Every HEADERS frame is treated as the final response head. ### Modifications - Updated `HTTP3FrameValidator.RequestStreamValidator` and `HTTPMessageParsingStateMachine` to support multiple interim responses with any 1xx status code. - Added associated tests. ### Result Servers can now send, and clients can now parse, any number of 1xx interim responses preceding the final response.
1 parent cbf3926 commit 54e5fc5

5 files changed

Lines changed: 146 additions & 22 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: 26 additions & 19 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
200-
case processedHeaders
201-
case processedTrailers
202-
case previousError
199+
case awaitingHeaders
200+
case awaitingBodyOrTrailers
201+
case messageComplete
202+
case failed
203203
}
204204

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

207207
package init() {}
208208

@@ -213,33 +213,40 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
213213

214214
package mutating func processFrame(frame: HTTP3Frame) -> ProcessFrameAction? {
215215
switch self.state {
216-
case .previousError:
216+
case .failed:
217217
return .none
218-
case .idle:
218+
case .awaitingHeaders:
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 = .awaitingHeaders
228+
} else {
229+
self.state = .awaitingBodyOrTrailers
230+
}
224231
return .returnPart(part)
225232
} catch {
226-
self.state = .previousError
233+
self.state = .failed
227234
return .emitError(error)
228235
}
229236
case .data, .cancelPush, .settings, .maxPushID, .pushPromise, .goaway:
230237
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
231238
fatalError("Unexpected frame")
232239
}
233-
case .processedHeaders:
240+
case .awaitingBodyOrTrailers:
234241
switch frame {
235242
case .headers(let headers):
236243
// If the incoming frame is of type 'headers', it must be the trailers
237244
do {
238245
let part = try Part.end(trailers: headers.fields)
239-
self.state = .processedTrailers
246+
self.state = .messageComplete
240247
return .returnPart(part)
241248
} catch {
242-
self.state = .previousError
249+
self.state = .failed
243250
return .emitError(error)
244251
}
245252
// Any number of data frames is fine. State stays as-is
@@ -249,7 +256,7 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
249256
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
250257
fatalError("Unexpected frame")
251258
}
252-
case .processedTrailers:
259+
case .messageComplete:
253260
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
254261
fatalError("More frames received after trailers")
255262
}
@@ -261,15 +268,15 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
261268

262269
package mutating func inputClosed() -> InputClosedAction? {
263270
switch self.state {
264-
case .idle:
265-
self.state = .processedTrailers
271+
case .awaitingHeaders:
272+
self.state = .messageComplete
266273
return .returnPart(.end())
267-
case .processedHeaders:
268-
self.state = .processedTrailers
274+
case .awaitingBodyOrTrailers:
275+
self.state = .messageComplete
269276
return .returnPart(.end())
270-
case .previousError:
277+
case .failed:
271278
return .none
272-
case .processedTrailers:
279+
case .messageComplete:
273280
// If we processed trailers, that means we sent an end, so don't send another one
274281
return .none
275282
}

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: 64 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

0 commit comments

Comments
 (0)