Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ let package = Package(
],
swiftSettings: extraSettings
),
.executableTarget(
name: "StreamResetExample",
dependencies: [
"ExampleSupport",
.product(name: "Logging", package: "swift-log"),
.product(name: "NIOHTTP2", package: "swift-nio-http2"),
.product(name: "NIOHTTP3", package: "swift-nio-http3", condition: .when(traits: ["HTTP3"])),
"NIOHTTPServer",
],
swiftSettings: extraSettings
),
.executableTarget(
name: "ConnectionHandlerExample",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,14 +459,18 @@ public enum CertificateVerificationResult: Sendable, Hashable {
}

/// An error representing certificate verification failure.
public struct VerificationError: Swift.Error, Hashable {
public struct VerificationError: Swift.Error, Hashable, CustomStringConvertible {
public let reason: String

/// Creates a verification error with the reason why verification failed.
/// - Parameter reason: The reason of why certificate verification failed.
public init(reason: String) {
self.reason = reason
}

public var description: String {
"Verification error: \(self.reason)"
}
}

/// Certificate verification succeeded.
Expand Down
66 changes: 62 additions & 4 deletions Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,69 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler {

/// `true` if we've committed to closing the connection after this response's
/// `.end` is written. Set when the buffer is flushed while request `.end` has
/// not yet arrived (so we add `Connection: close`). Cleared when a new request
/// not yet arrived, and when a request is aborted (so a still-buffered head
/// picks up `Connection: close` on its way out). Cleared when a new request
/// begins.
private var closeAfterResponseEnd: Bool = false

private var finalResponseState: FinalResponseState = .notStarted

/// Asks the handler to abandon the current response and close the connection.
///
/// Fired as a user outbound event by the server when a request handler throws. HTTP/1.1 has no per-stream reset, so
/// aborting means closing the connection — and how much of the response can still be salvaged depends on how far it
/// has progressed, which only this handler knows.
struct RequestAborted: Sendable {
/// The response to send when the handler had not started a response yet.
///
/// It is amended with `Connection: close` before being written. If a response has already been written this is
/// ignored.
var responseIfNotStarted: HTTPResponse
}

func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise<Void>?) {
guard let abort = event as? RequestAborted else {
context.triggerUserOutboundEvent(event, promise: promise)
return
}

self.abortResponse(context: context, responseIfNotStarted: abort.responseIfNotStarted)
promise?.succeed()
}

/// Abandons the in-flight response and closes the connection.
private func abortResponse(context: ChannelHandlerContext, responseIfNotStarted: HTTPResponse) {
switch self.finalResponseState {
case .notStarted:
// Nothing has been written for this request, so a complete response can still be sent.
var response = responseIfNotStarted
response.headerFields[.connection] = "close"
self.closeAfterResponseEnd = true
self.finalResponseState = .streaming

context.write(self.wrapOutboundOut(.head(response)), promise: nil)
context.write(self.wrapOutboundOut(.end(nil)), promise: nil)

case .buffering:
// The head has not reached the wire yet, so it can still be amended with `Connection: close`.
//
// No response `.end` is synthesized: the handler abandoned this response, and for a chunked body `.end`
// writes the terminating chunk, which would tell the client the truncated response was complete. Leaving it
// out means the client sees an incomplete message, which is the signal we want. For a `Content-Length` body
// `.end` writes no bytes at all, so omitting it changes nothing.
self.closeAfterResponseEnd = true
self.flushBuffer(context: context)

case .streaming:
// The head is already on the wire and cannot be recalled. The response is abandoned, so the client observes
// it as truncated — again without a fabricated `.end`.
()
}

context.flush()
context.close(mode: .output, promise: nil)
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = self.unwrapInboundIn(data)
switch part {
Expand Down Expand Up @@ -154,12 +211,13 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler {
}

/// Releases buffered response parts to the pipeline. If request `.end` has not
/// yet arrived, amend the head with `Connection: close` and arrange to close
/// the connection once response `.end` is written.
/// yet arrived, or we have already committed to closing, amend the head with
/// `Connection: close` and arrange to close the connection once response `.end`
/// is written.
private func flushBuffer(context: ChannelHandlerContext) {
guard case .buffering(var head, let additional) = self.finalResponseState else { return }

if !self.requestEndReceived {
if self.closeAfterResponseEnd || !self.requestEndReceived {
// Amend the head with `Connection: close` before flushing.
if case .head(var response) = head.part {
response.headerFields[.connection] = "close"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

/// An error that maps to the `RST_STREAM` error code sent when an HTTP/2 request is aborted.
///
/// A request handler reports a failure by throwing. The server does not surface that error to any caller: instead it
/// aborts the exchange on the wire, which over HTTP/2 means resetting the request's stream. Conform an error to this
/// protocol to choose the error code carried by that `RST_STREAM` frame.
///
/// An error that does not conform is reset with `INTERNAL_ERROR` (`0x02`).
///
/// ## Example
///
/// A proxy that fails to establish a tunnel reports it as a `CONNECT` error:
///
/// ```swift
/// struct TunnelFailure: HTTPServerHTTP2StreamResetErrorConvertible {
/// var http2StreamResetCode: UInt32 { 0x0a } // CONNECT_ERROR
/// }
///
/// try await server.serve { request, context, reader, responseSender in
/// guard let tunnel = try? await openTunnel(to: request.authority) else {
/// throw TunnelFailure()
/// }
/// // ...
/// }
/// ```
public protocol HTTPServerHTTP2StreamResetErrorConvertible: Error {
/// The `RST_STREAM` error code to send, as its numeric value on the wire.
///
/// The codes and their values are defined by RFC 9113 § 7 — for example `0x08` for `CANCEL`, `0x0a` for
/// `CONNECT_ERROR`, or `0x02` for `INTERNAL_ERROR`.
///
/// This code is used only when the request is served over HTTP/2.
var http2StreamResetCode: UInt32 { get }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

#if HTTP3
/// An error that maps to the error codes sent when an HTTP/3 request is aborted.
///
/// A request handler reports a failure by throwing. The server does not surface that error to any caller: instead it
/// aborts the exchange on the wire, which over HTTP/3 means resetting the request's stream and asking the client to
/// stop sending the request body. Conform an error to this protocol to choose the error codes carried by those frames.
///
/// An error that does not conform is reset with `H3_INTERNAL_ERROR` (`0x0102`).
///
/// ## Example
///
/// A proxy that fails to establish a tunnel reports it as a `CONNECT` error:
///
/// ```swift
/// struct TunnelFailure: HTTPServerHTTP3StreamResetErrorConvertible {
/// var http3StreamResetCode: UInt64 { 0x010f } // H3_CONNECT_ERROR
/// var http3StopSendingCode: UInt64 { 0x010f } // H3_CONNECT_ERROR
/// }
///
/// try await server.serve { request, context, reader, responseSender in
/// guard let tunnel = try? await openTunnel(to: request.authority) else {
/// throw TunnelFailure()
/// }
/// // ...
/// }
/// ```
public protocol HTTPServerHTTP3StreamResetErrorConvertible: Error {
/// The application error code to send when abandoning the response, as its numeric value on the wire.
///
/// The codes and their values are defined by RFC 9114 § 8.1 — for example `0x010f` for `H3_CONNECT_ERROR`,
/// `0x010c` for `H3_REQUEST_CANCELLED`, or `0x0102` for `H3_INTERNAL_ERROR`.
///
/// The value must be less than 2^62, the largest value the transport can encode; an out-of-range value is replaced
/// with `H3_INTERNAL_ERROR`. This code is used only when the request is served over HTTP/3.
var http3StreamResetCode: UInt64 { get }

/// The application error code to send when asking the client to stop sending the request body that the server is no
/// longer reading, as its numeric value on the wire.
///
/// The same code space and range restriction as ``http3StreamResetCode`` applies.
var http3StopSendingCode: UInt64 { get }
}
#endif
17 changes: 17 additions & 0 deletions Sources/NIOHTTPServer/LoggingKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

enum LoggingKeys {
static var `protocol`: String { "protocol" }
}
84 changes: 84 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer+AbortRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIOCore
import NIOHTTP2
import NIOHTTPTypes
import NIOHTTPTypesHTTP2

#if HTTP3
import HTTP3
import NIOQUICHelpers
#endif

@available(anyAppleOS 26.0, *)
extension NIOHTTPServer {
/// Aborts the exchange carrying a request on the wire, after that request's handler threw `error`.
///
/// Which mechanism applies depends on the protocol serving the request:
/// - HTTP/1.1 has no stream to reset, so the response is abandoned and the connection is closed: a decision delegated to
/// ``HTTPKeepAliveHandler``, which already tracks how far the response has progressed and owns the
/// `Connection: close` handling.
/// - HTTP/2 and HTTP/3 reset the request's own stream, with the error codes `error` describes.
/// - HTTP/3 also asks the client to STOP_SENDING.
static func abortRequest(requestContext: RequestContext, error: any Error) {
let channel = requestContext.channel

switch requestContext.connectionContext.httpVersion {
case .plaintextHTTP1_1, .http1_1:
var response = HTTPResponse(status: .internalServerError)
response.headerFields[.contentLength] = "0"
Comment thread
aryan-25 marked this conversation as resolved.
channel.triggerUserOutboundEvent(
HTTPKeepAliveHandler.RequestAborted(responseIfNotStarted: response),
promise: nil
)

case .http2:
// An error that does not describe its own code is reset with `INTERNAL_ERROR`.
let resetCode =
(error as? any HTTPServerHTTP2StreamResetErrorConvertible)
.map { HTTP2ErrorCode(networkCode: Int($0.http2StreamResetCode)) } ?? .internalError

// `HTTP2FramePayloadToHTTPServerCodec` translates this event into a `RST_STREAM` frame.
channel.triggerUserOutboundEvent(
NIOHTTP2FramePayloadToHTTPEvent.reset(code: resetCode),
promise: nil
)

#if HTTP3
case .http3:
let http3Error = error as? any HTTPServerHTTP3StreamResetErrorConvertible
let resetCode = Self.quicErrorCode(http3Error?.http3StreamResetCode)
let stopSendingCode = Self.quicErrorCode(http3Error?.http3StopSendingCode)

// `RESET_STREAM` abandons the response direction and `STOP_SENDING` asks the client to
// stop sending the request body.
channel.triggerUserOutboundEvent(QUICResetStreamEvent(code: resetCode), promise: nil)
channel.triggerUserOutboundEvent(QUICStopSendingEvent(code: stopSendingCode), promise: nil)
#endif
}
}

#if HTTP3
/// Converts a raw HTTP/3 error code into a QUIC application error code.
///
/// Substitutes `H3_INTERNAL_ERROR` when the error described no code, or described one that cannot be represented as
/// a QUIC variable-length integer.
private static func quicErrorCode(_ rawValue: UInt64?) -> QUICApplicationErrorCode {
// The force unwrap is safe: `H3_INTERNAL_ERROR` (0x0102) is always representable as a QUIC varint.
rawValue.flatMap(QUICApplicationErrorCode.init)
?? QUICApplicationErrorCode(HTTP3ErrorCode.internalError.rawValue)!
}
#endif
}
19 changes: 15 additions & 4 deletions Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ extension NIOHTTPServer {
public struct Connection: ~Copyable, Sendable {
/// Per-protocol state.
///
/// - `http1_1` carries the request-channel's already-running inbound stream and outbound writer (the dispatcher
/// owns the channel and drives `executeThenClose`, so the writer is finished cleanly even if the connection
/// handler returns without calling ``handleRequests(handler:)``).
/// - `http1_1` carries the request channel plus its already-running inbound stream and outbound writer (the
/// dispatcher owns the channel and drives `executeThenClose`, so the writer is finished cleanly even if the
/// connection handler returns without calling ``handleRequests(handler:)``).
/// - `http2` carries the connection channel and stream multiplexer.
/// - `http3` carries an ``HTTP3ServerConnection``.
enum HTTPProtocol: Sendable {
case http1_1(
channel: any Channel,
inbound: NIOAsyncChannelInboundStream<HTTPRequestPart>,
outbound: NIOAsyncChannelOutboundWriter<HTTPResponsePart>
)
Expand Down Expand Up @@ -74,6 +75,12 @@ extension NIOHTTPServer {
/// Each request received on this connection is dispatched to `handler`. The
/// loop returns when the peer closes the connection, the server shuts down,
/// or an error occurs.
///
/// A handler reports a failure by throwing. The error is not propagated out of this method: it aborts the
/// exchange carrying that request on the wire, closing the connection over HTTP/1.1, or resetting the stream
/// over HTTP/2 and HTTP/3. Conform the error to ``HTTPServerHTTP2StreamResetErrorConvertible`` and
/// ``HTTPServerHTTP3StreamResetErrorConvertible`` as appropriate to choose the protocol error codes; see
/// ``NIOHTTPServer/serve(handler:)`` for the full description.
public consuming func handleRequests<Handler: HTTPServerRequestHandler>(
handler: Handler
) async
Expand All @@ -85,8 +92,9 @@ extension NIOHTTPServer {
let server = self.server
let context = self.context
switch self.httpProtocol {
case .http1_1(let inbound, let outbound):
case .http1_1(let channel, let inbound, let outbound):
await server.handleHTTP1RequestLoop(
channel: channel,
inbound: inbound,
outbound: outbound,
handler: handler,
Expand All @@ -111,6 +119,9 @@ extension NIOHTTPServer {
/// Convenience overload accepting a closure instead of a
/// ``HTTPServerRequestHandler`` conformance.
///
/// Throwing from the closure aborts the exchange carrying that request on the wire rather than propagating the
/// error; see ``handleRequests(handler:)``.
///
/// ```swift
/// try await server.serve { connection, context in
/// try await connection.handleRequests { request, requestContext, reader, responseSender in
Expand Down
3 changes: 2 additions & 1 deletion Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public import X509
@available(anyAppleOS 26.0, *)
extension NIOHTTPServer {
/// The application-level HTTP version negotiated for a connection.
public enum HTTPVersion: String, Sendable, Hashable {
@nonexhaustive
public enum HTTPVersion: String, Sendable, Hashable, CaseIterable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to mark this as @nonexhaustive otherwise we won't be able to support a hypothethical HTTP/4.

case plaintextHTTP1_1 = "Plaintext HTTP/1.1"
case http1_1 = "HTTP/1.1"
case http2 = "HTTP/2"
Expand Down
Loading
Loading