-
Notifications
You must be signed in to change notification settings - Fork 10
Add reset stream API #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add reset stream API #104
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,100 @@ | ||||||
| //===----------------------------------------------------------------------===// | ||||||
| // | ||||||
| // 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 | ||||||
| public import NIOHTTP2 | ||||||
| import NIOHTTPTypes | ||||||
| import NIOHTTPTypesHTTP2 | ||||||
|
|
||||||
| #if HTTP3 | ||||||
| import NIOQUICHelpers | ||||||
| #endif // HTTP3 | ||||||
|
|
||||||
| @available(anyAppleOS 26.0, *) | ||||||
| extension NIOHTTPServer { | ||||||
| /// The protocol-specific surface for resetting the stream carrying a request. | ||||||
| /// | ||||||
| /// Stream resets only exists over HTTP/2 and HTTP/3. The only abrupt tear-down mechanism available | ||||||
| /// for HTTP/1.1 is closing the connection. | ||||||
| @nonexhaustive | ||||||
| public enum StreamReset: ~Copyable { | ||||||
| /// The protocol has no per-stream coded reset (for example HTTP/1.1). | ||||||
| /// | ||||||
| /// There is nothing to reset with a code here. Returning from the handler without concluding the response | ||||||
| /// aborts the exchange and the connection is closed. | ||||||
| case unavailable | ||||||
|
|
||||||
| /// The connection is HTTP/2; ``HTTP2StreamReset`` sends a `RST_STREAM`. | ||||||
| case http2(HTTP2StreamReset) | ||||||
|
|
||||||
| #if HTTP3 | ||||||
| /// The connection is HTTP/3; ``HTTP3StreamReset`` sends a QUIC `RESET_STREAM`. | ||||||
| case http3(HTTP3StreamReset) | ||||||
| #endif // HTTP3 | ||||||
| } | ||||||
|
|
||||||
| /// Resets an HTTP/2 stream by sending a `RST_STREAM` frame with a chosen error code. | ||||||
| public struct HTTP2StreamReset: ~Copyable { | ||||||
| private let channel: any Channel | ||||||
|
|
||||||
| init(channel: any Channel) { | ||||||
| self.channel = channel | ||||||
| } | ||||||
|
|
||||||
| /// Sends a `RST_STREAM` frame for this stream with the provided error code. | ||||||
| /// | ||||||
| /// - Parameter code: The `RST_STREAM` error code to send. | ||||||
| public consuming func reset(code: HTTP2ErrorCode) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this method executes I/O and it must be |
||||||
| // The `HTTP2FramePayloadToHTTPServerCodec` on the stream channel translates this event into an `RST_STREAM` | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
|
||||||
| // frame. | ||||||
| self.channel.triggerUserOutboundEvent( | ||||||
| NIOHTTP2FramePayloadToHTTPEvent.reset(code: code), | ||||||
| promise: nil | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| #if HTTP3 | ||||||
| /// Resets an HTTP/3 stream by sending a QUIC `RESET_STREAM` frame with a chosen error code. | ||||||
| public struct HTTP3StreamReset: ~Copyable { | ||||||
| private let channel: any Channel | ||||||
|
|
||||||
| init(channel: any Channel) { | ||||||
| self.channel = channel | ||||||
| } | ||||||
|
|
||||||
| /// Sends a QUIC `RESET_STREAM` frame for this stream with the provided error code. | ||||||
| /// | ||||||
| /// - Parameter code: The QUIC application error code to send. It must be a valid application error code that is | ||||||
| /// less than 2^62 (the maximum QUIC variable-length integer value). If the code is out of range, the stream | ||||||
| /// is not reset. | ||||||
| public consuming func reset(code: UInt64) { | ||||||
| guard let resetCode = QUICApplicationErrorCode(code) else { return } | ||||||
|
|
||||||
| self.channel.triggerUserOutboundEvent(QUICResetStreamEvent(code: resetCode), promise: nil) | ||||||
| } | ||||||
| } | ||||||
| #endif // HTTP3 | ||||||
| } | ||||||
|
|
||||||
| @available(*, unavailable) | ||||||
| extension NIOHTTPServer.StreamReset: Sendable {} | ||||||
|
|
||||||
| @available(*, unavailable) | ||||||
| extension NIOHTTPServer.HTTP2StreamReset: Sendable {} | ||||||
|
|
||||||
| #if HTTP3 | ||||||
| @available(*, unavailable) | ||||||
| extension NIOHTTPServer.HTTP3StreamReset: Sendable {} | ||||||
| #endif // HTTP3 | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ extension NIOHTTPServer { | |
| public struct ResponseSender: HTTPResponseSender, ~Copyable { | ||
| let writer: NIOAsyncChannelOutboundWriter<HTTPResponsePart> | ||
| let writerState: WriterState | ||
| let streamReset: NIOHTTPServer.StreamReset | ||
|
|
||
| public mutating func sendInformational(_ response: HTTPResponse) async throws { | ||
| precondition(response.status.kind == .informational) | ||
|
|
@@ -30,7 +31,23 @@ extension NIOHTTPServer { | |
| public consuming func send(_ response: HTTPResponse) async throws -> Writer { | ||
| precondition(response.status.kind != .informational) | ||
| try await self.writer.write(.head(response)) | ||
| return Writer(writer: self.writer, writerState: self.writerState) | ||
| return Writer( | ||
| writer: self.writer, | ||
| writerState: self.writerState, | ||
| streamReset: self.streamReset | ||
| ) | ||
| } | ||
|
|
||
| /// Abandons the response and resets the stream carrying this request. | ||
| /// | ||
| /// Call this instead of ``send(_:)`` when the request should be aborted before any response head is sent. This | ||
| /// consumes the sender, so no response can be sent afterwards. | ||
| /// | ||
| /// - Parameter body: A closure that is provided a ``NIOHTTPServer/StreamReset`` instance from which the request | ||
| /// stream can be reset with a transport-specific error code. | ||
| public consuming func reset(_ body: (consuming NIOHTTPServer.StreamReset) throws -> Void) throws { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like that this is a method on the response sender but I am not sure we need the closure yet. Wouldn't it suffice if we just made this take an
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could potentially return the As to consuming the reader, I played with that, but for H3 where only the sending direction is closed, we may still have bytes to read. |
||
| self.writerState.markReset(self.streamReset) | ||
| return try body(self.streamReset) | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -43,6 +60,30 @@ extension NIOHTTPServer.ResponseSender { | |
| } | ||
|
|
||
| let wrapped: Mutex<Wrapped> = .init(.init()) | ||
|
|
||
| /// Records that the handler chose to reset the stream instead of | ||
| /// concluding the response normally. | ||
| /// | ||
| /// On HTTP/2 (`RST_STREAM`) and HTTP/3 (`RESET_STREAM`) the coded reset is | ||
| /// itself a clean conclusion of the exchange, so the response is marked | ||
| /// as concluded to avoid an erroneous "did not conclude the response" | ||
| /// teardown. When no coded reset is available (HTTP/1.1) there is nothing | ||
| /// to send: leaving the response unconcluded is deliberate, so the | ||
| /// connection is torn down. | ||
| func markReset(_ streamReset: borrowing NIOHTTPServer.StreamReset) { | ||
| switch streamReset { | ||
| case .unavailable: | ||
| () | ||
|
|
||
| case .http2: | ||
| self.wrapped.withLock { $0.finishedWriting = true } | ||
|
|
||
| #if HTTP3 | ||
| case .http3: | ||
| self.wrapped.withLock { $0.finishedWriting = true } | ||
| #endif // HTTP3 | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public struct Writer: CallerAsyncWriter, ~Copyable { | ||
|
|
@@ -57,6 +98,8 @@ extension NIOHTTPServer.ResponseSender { | |
|
|
||
| let writerState: WriterState | ||
|
|
||
| let streamReset: NIOHTTPServer.StreamReset | ||
|
|
||
| public mutating func write( | ||
| buffer: inout some RangeReplaceableContainer<UInt8> & ~Copyable | ||
| ) async throws(WriteFailure) { | ||
|
|
@@ -73,7 +116,7 @@ extension NIOHTTPServer.ResponseSender { | |
| if span.isEmpty { | ||
| done = true | ||
| } else { | ||
| byteBuffer.writeBytes(span.span.bytes) | ||
| unsafe byteBuffer.writeBytes(span.span.bytes) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -98,7 +141,7 @@ extension NIOHTTPServer.ResponseSender { | |
| if span.isEmpty { | ||
| done = true | ||
| } else { | ||
| byteBuffer.writeBytes(span.span.bytes) | ||
| unsafe byteBuffer.writeBytes(span.span.bytes) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -107,6 +150,18 @@ extension NIOHTTPServer.ResponseSender { | |
| try await self.writer.write(.end(finalElement)) | ||
| self.writerState.wrapped.withLock { $0.finishedWriting = true } | ||
| } | ||
|
|
||
| /// Abandons the in-flight response and resets the stream carrying this request. | ||
| /// | ||
| /// Call this instead of ``finish(buffer:finalElement:)`` when a response that has already started must be | ||
| /// aborted. This consumes the writer, so no further body or trailers can be written. | ||
| /// | ||
| /// - Parameter body: A closure that is provided a ``NIOHTTPServer/StreamReset`` instance from which the request | ||
| /// stream can be reset with a transport-specific error code. | ||
| public consuming func reset(_ body: (consuming NIOHTTPServer.StreamReset) throws -> Void) throws { | ||
| self.writerState.markReset(self.streamReset) | ||
| return try body(self.streamReset) | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not very happy that this surfaces different APIs for H/2 and H/3. In the end both are just wrappers around
Intwhich is treated as an application error code right? Do we need to surface this difference really?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There were two reasons why I decided to go this way, but basically it boils down to request handlers potentially wanting to do different things depending on the HTTP version they're running on (to fail with different error codes for example). The first reason is because the errors that can be surfaced aren't the same for H2 and H3; I considered having a set of common errors that could be mapped across each version, but not many of them actually overlap, and having some "raw" code where most of the errors would have to live felt weird.
The second and perhaps more importantly is that H1 does not support resets at all, so handlers may want to do close the connection instead or do something different altogether.