-
Notifications
You must be signed in to change notification settings - Fork 5
Add connection backpressure and timeouts #72
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?
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 |
|---|---|---|
|
|
@@ -65,7 +65,9 @@ extension NIOHTTPServerConfiguration { | |
| config: snapshot.scoped(to: "transportSecurity"), | ||
| customCertificateVerificationCallback: customCertificateVerificationCallback | ||
| ), | ||
| backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")) | ||
| backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")), | ||
| maxConnections: snapshot.int(forKey: "maxConnections"), | ||
| connectionTimeouts: .init(config: snapshot.scoped(to: "connectionTimeouts")) | ||
| ) | ||
| } | ||
| } | ||
|
|
@@ -446,4 +448,29 @@ extension CertificateVerificationMode { | |
| } | ||
| } | ||
| } | ||
| @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) | ||
| extension NIOHTTPServerConfiguration.ConnectionTimeouts { | ||
| /// Initialize connection timeouts configuration from a config reader. | ||
| /// | ||
| /// ## Configuration keys: | ||
| /// - `idle` (int, optional, default: 60): Maximum time in seconds a connection can remain idle. | ||
| /// Set to `null` to disable. | ||
|
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. "Set to
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. I changed the docs but also removed the coalescing from the SwiftConfiguration initialiser - I think it's clearer that if you set things to nil then you don't want anything. |
||
| /// - `readHeader` (int, optional, default: 30): Maximum time in seconds to receive request headers. | ||
| /// Set to `null` to disable. | ||
| /// - `readBody` (int, optional, default: 60): Maximum time in seconds to receive the request body. | ||
| /// Set to `null` to disable. | ||
| /// | ||
| /// - Parameter config: The configuration reader. | ||
| public init(config: ConfigSnapshotReader) { | ||
| self.init( | ||
| idle: config.int(forKey: "idle").map { .seconds($0) } | ||
| ?? Self.defaultIdle, | ||
| readHeader: config.int(forKey: "readHeader").map { .seconds($0) } | ||
| ?? Self.defaultReadHeader, | ||
| readBody: config.int(forKey: "readBody").map { .seconds($0) } | ||
| ?? Self.defaultReadBody | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| #endif // Configuration | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // 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 | ||
|
|
||
| /// A channel handler installed on the server (parent) channel that limits the | ||
| /// number of concurrent connections by gating `read()` calls. | ||
| /// | ||
| /// When the number of active connections reaches `maxConnections`, this handler | ||
| /// stops forwarding `read()` events, which prevents NIO from calling `accept()` | ||
| /// on the listening socket. When a connection closes and count drops below the | ||
| /// limit, `read()` is re-triggered to resume accepting. | ||
| final class ConnectionLimitHandler: ChannelDuplexHandler { | ||
| typealias InboundIn = Channel | ||
| typealias InboundOut = Channel | ||
| typealias OutboundIn = Channel | ||
|
|
||
| private let maxConnections: Int | ||
| private var activeConnections: Int = 0 | ||
|
|
||
| init(maxConnections: Int) { | ||
| self.maxConnections = maxConnections | ||
| } | ||
|
|
||
| func channelRead(context: ChannelHandlerContext, data: NIOAny) { | ||
| let childChannel = self.unwrapInboundIn(data) | ||
| self.activeConnections += 1 | ||
|
|
||
| let loopBoundSelf = NIOLoopBound(self, eventLoop: context.eventLoop) | ||
| let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) | ||
| let eventLoop = context.eventLoop | ||
| childChannel.closeFuture.whenComplete { _ in | ||
| eventLoop.execute { | ||
|
Member
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. why do we need to hop here? we already should be on the correct EL. |
||
| let `self` = loopBoundSelf.value | ||
| let context = loopBoundContext.value | ||
| self.activeConnections -= 1 | ||
| if self.activeConnections <= self.maxConnections { | ||
| context.read() | ||
|
Member
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 don't think we should read here automatically. I think we should only read here, if we have seen a read call before that we didn't immediately forward. Other channels in the pipeline might want to stop backpressure for their own reasons. This auto read call assumes we are the only channel in the pipeline. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| context.fireChannelRead(data) | ||
| } | ||
|
|
||
| func read(context: ChannelHandlerContext) { | ||
| if self.activeConnections <= self.maxConnections { | ||
|
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. Is this channel handler guaranteed to only prevent the call to Because |
||
| context.read() | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -322,6 +322,64 @@ public struct NIOHTTPServer: HTTPServer { | |
| secureUpgradeChannel.channel.close(promise: nil) | ||
| } | ||
| } | ||
|
|
||
| /// Adds timeout handlers (idle, read header, read body) to a child channel pipeline. | ||
| /// | ||
| /// Only handlers for non-nil timeouts are installed. This is called for both | ||
| /// HTTP/1.1 per-connection channels and HTTP/2 per-stream channels. | ||
| func addTimeoutHandlers(to channel: any Channel) throws { | ||
|
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. Should these methods be an extension on |
||
| let timeouts = self.configuration.connectionTimeouts | ||
|
|
||
| if let idle = timeouts.idle { | ||
|
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. Looks like these three |
||
| let idleTimeAmount = TimeAmount(idle) | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) | ||
| ) | ||
| try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) | ||
| } | ||
|
|
||
| if let readHeader = timeouts.readHeader { | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) | ||
| ) | ||
| } | ||
|
|
||
| if let readBody = timeouts.readBody { | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /// Adds only idle timeout handlers to a channel. Used for HTTP/2 connection-level channels | ||
| /// where read header/body timeouts are handled per-stream. | ||
| func addIdleTimeoutHandlers(to channel: any Channel) throws { | ||
| if let idle = self.configuration.connectionTimeouts.idle { | ||
| let idleTimeAmount = TimeAmount(idle) | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) | ||
|
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. Should we allow both the read and write idle timeouts to be configurable? |
||
| ) | ||
| try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) | ||
| } | ||
| } | ||
|
|
||
| /// Adds only read header and body timeout handlers to a channel. Used for HTTP/2 per-stream | ||
| /// channels where idle timeout is handled at the connection level. | ||
| func addReadTimeoutHandlers(to channel: any Channel) throws { | ||
| let timeouts = self.configuration.connectionTimeouts | ||
|
|
||
| if let readHeader = timeouts.readHeader { | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) | ||
| ) | ||
| } | ||
|
|
||
| if let readBody = timeouts.readBody { | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.