diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 241f287..02c7878 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -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,23 @@ 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: nil): Maximum time in seconds a connection can remain idle. + /// - `readHeader` (int, optional, default: nil): Maximum time in seconds to receive request headers. + /// - `readBody` (int, optional, default: nil): Maximum time in seconds to receive the request body. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + idle: config.int(forKey: "idle").map { .seconds($0) }, + readHeader: config.int(forKey: "readHeader").map { .seconds($0) }, + readBody: config.int(forKey: "readBody").map { .seconds($0) } + ) + } +} + #endif // Configuration diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index b64f29e..cf0f344 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -230,6 +230,51 @@ public struct NIOHTTPServerConfiguration: Sendable { } } + /// Configuration for connection timeouts. + /// + /// Timeouts are enabled by default with reasonable values to protect against + /// slow or idle connections. Individual timeouts can be disabled by setting + /// them to `nil`. + public struct ConnectionTimeouts: Sendable { + /// Maximum time a connection can remain idle (no data read or written) + /// before being closed. `nil` means no idle timeout. + public var idle: Duration? + + /// Maximum time allowed to receive the complete request headers + /// after a connection is established. `nil` means no timeout. + public var readHeader: Duration? + + /// Maximum time allowed to receive the complete request body + /// after headers have been received. `nil` means no timeout. + public var readBody: Duration? + + /// - Parameters: + /// - idle: Maximum idle time before the connection is closed. + /// - readHeader: Maximum time to receive request headers. + /// - readBody: Maximum time to receive the request body. + public init( + idle: Duration? = Self.defaultIdle, + readHeader: Duration? = Self.defaultReadHeader, + readBody: Duration? = Self.defaultReadBody + ) { + self.idle = idle + self.readHeader = readHeader + self.readBody = readBody + } + + @inlinable + static var defaultIdle: Duration? { .seconds(60) } + + @inlinable + static var defaultReadHeader: Duration? { .seconds(30) } + + @inlinable + static var defaultReadBody: Duration? { .seconds(60) } + + /// Default timeout values: 60s idle, 30s read header, 60s read body. + public static var defaults: Self { .init() } + } + /// Network binding configuration public var bindTarget: BindTarget @@ -242,6 +287,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Backpressure strategy to use in the server. public var backpressureStrategy: BackPressureStrategy + /// The maximum number of concurrent connections the server will accept. + /// + /// When this limit is reached, the server stops accepting new connections + /// until existing ones close. `nil` means unlimited (the default). + public var maxConnections: Int? + + /// Configuration for connection timeouts. + public var connectionTimeouts: ConnectionTimeouts + /// Create a new configuration. /// - Parameters: /// - bindTarget: A ``BindTarget``. @@ -249,11 +303,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - transportSecurity: The transport security mode (plaintext, TLS, or mTLS). /// - backpressureStrategy: A ``BackPressureStrategy``. /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. + /// - maxConnections: The maximum number of concurrent connections. `nil` means unlimited. + /// - connectionTimeouts: The connection timeout configuration. public init( bindTarget: BindTarget, supportedHTTPVersions: Set, transportSecurity: TransportSecurity, - backpressureStrategy: BackPressureStrategy = .defaults + backpressureStrategy: BackPressureStrategy = .defaults, + maxConnections: Int? = nil, + connectionTimeouts: ConnectionTimeouts = .defaults ) throws { // If `transportSecurity`` is set to `.plaintext`, the server can only support HTTP/1.1. // To support HTTP/2, `transportSecurity` must be set to `.tls` or `.mTLS`. @@ -267,10 +325,16 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } + if let maxConnections, maxConnections <= 0 { + throw NIOHTTPServerConfigurationError.invalidMaxConnections + } + self.bindTarget = bindTarget self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity self.backpressureStrategy = backpressureStrategy + self.maxConnections = maxConnections + self.connectionTimeouts = connectionTimeouts } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 2001f29..4b399cd 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -16,6 +16,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case noSupportedHTTPVersionsSpecified case incompatibleTransportSecurity + case invalidMaxConnections var description: String { switch self { @@ -24,6 +25,9 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case .incompatibleTransportSecurity: "Invalid configuration: only HTTP/1.1 can be served over plaintext. `transportSecurity` must be set to (m)TLS for serving HTTP/2." + + case .invalidMaxConnections: + "Invalid configuration: `maxConnections` must be greater than 0." } } } diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift new file mode 100644 index 0000000..afb47f2 --- /dev/null +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// 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 + private var pendingRead: Bool = false + + 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 { + let `self` = loopBoundSelf.value + let context = loopBoundContext.value + `self`.activeConnections -= 1 + if `self`.pendingRead && `self`.activeConnections <= `self`.maxConnections { + `self`.pendingRead = false + context.read() + } + } + } + + context.fireChannelRead(data) + } + + func read(context: ChannelHandlerContext) { + if self.activeConnections <= self.maxConnections { + context.read() + } else { + self.pendingRead = true + } + } +} diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 005fce3..f071eaf 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -29,8 +29,8 @@ let serverConfiguration = try NIOHTTPServerConfiguration(config: config) ### Configuration key reference -``NIOHTTPServerConfiguration`` is comprised of four components. Provide the configuration for each component under its -respective key prefix. +``NIOHTTPServerConfiguration`` is comprised of several components. Provide the configuration for each component under +its respective key prefix. > Important: HTTP/2 cannot be served over plaintext. If `"http2"` is included in `http.versions`, the transport > security must be set to `"tls"` or `"mTLS"`. @@ -57,6 +57,10 @@ respective key prefix. | | `certificateVerificationMode` | `string` | Required for `"mTLS"`, permitted values: `"optionalVerification"`, `"noHostnameVerification"` | - | | `backpressureStrategy` | `lowWatermark` | `int` | Optional | 2 | | | `highWatermark` | `int` | Optional | 10 | +| - | `maxConnections` | `int` | Optional | nil | +| `connectionTimeouts` | `idle` | `int` | Optional | nil | +| | `readHeader` | `int` | Optional | nil | +| | `readBody` | `int` | Optional | nil | The `credentialSource` determines how server credentials are provided: @@ -108,6 +112,12 @@ key were omitted. "backpressureStrategy": { "lowWatermark": 2, // default: 2 "highWatermark": 10 // default: 10 + }, + "maxConnections": 1000, // default: nil (unlimited) + "connectionTimeouts": { + "idle": 60, // default: nil (no timeout) + "readHeader": 30, // default: nil (no timeout) + "readBody": 60 // default: nil (no timeout) } } ``` diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 8cd4a0d..72c485a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -52,6 +52,12 @@ extension NIOHTTPServer { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } } } .bind(host: host, port: port) { channel in @@ -77,6 +83,8 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) + try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) + return try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: asyncChannelConfiguration diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 65657f7..650235c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -97,6 +97,12 @@ extension NIOHTTPServer { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } } } .bind(host: host, port: port) { channel in @@ -120,6 +126,8 @@ extension NIOHTTPServer { channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) + try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) + return try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: .init( @@ -141,7 +149,10 @@ extension NIOHTTPServer { ) > { channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + // Add idle timeout at the connection level for HTTP/2 + try channel.addIdleTimeoutHandlers(self.configuration.connectionTimeouts) + + return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, connectionManagerConfiguration: .init( maxIdleTime: nil, @@ -158,6 +169,9 @@ extension NIOHTTPServer { HTTP2FramePayloadToHTTPServerCodec() ) + // Add read header and body timeouts per-stream for HTTP/2 + try http2StreamChannel.addReadTimeoutHandlers(self.configuration.connectionTimeouts) + return try NIOAsyncChannel( wrappingChannelSynchronously: http2StreamChannel, configuration: .init( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 6e7e5e0..f7c78d0 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -322,6 +322,41 @@ public struct NIOHTTPServer: HTTPServer { secureUpgradeChannel.channel.close(promise: nil) } } + +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Channel { + /// Adds timeout handlers (idle, read header, read body) to the 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(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { + try self.addIdleTimeoutHandlers(timeouts) + try self.addReadTimeoutHandlers(timeouts) + } + + /// Adds only idle timeout handlers to the channel. Used for HTTP/2 connection-level channels + /// where read header/body timeouts are handled per-stream. + func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { + if let idle = timeouts.idle { + try self.pipeline.syncOperations.addHandler( + ConnectionIdleTimeoutHandler(timeout: TimeAmount(idle)) + ) + } + } + + /// Adds only read header and body timeout handlers to the channel. Used for HTTP/2 per-stream + /// channels where idle timeout is handled at the connection level. + func addReadTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { + let readHeader = timeouts.readHeader.map { TimeAmount($0) } + let readBody = timeouts.readBody.map { TimeAmount($0) } + if readHeader != nil || readBody != nil { + try self.pipeline.syncOperations.addHandler( + RequestTimeoutHandler(readHeaderTimeout: readHeader, readBodyTimeout: readBody) + ) + } + } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift new file mode 100644 index 0000000..6998e65 --- /dev/null +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOHTTPTypes + +/// A channel handler that closes the connection after a period of inactivity. +/// +/// The timeout is scheduled when the channel becomes active and is rescheduled +/// whenever a read or write occurs. If the timeout fires without any activity, +/// the connection is closed. +/// +/// This replaces the combination of NIO's `IdleStateHandler` and a separate +/// handler to react to idle events. +final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannelHandler { + typealias InboundIn = NIOAny + typealias InboundOut = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny + + private let timeout: TimeAmount + private var scheduledTimeout: Scheduled? + + init(timeout: TimeAmount) { + self.timeout = timeout + } + + func channelActive(context: ChannelHandlerContext) { + self.scheduleTimeout(context: context) + context.fireChannelActive() + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.scheduleTimeout(context: context) + context.fireChannelRead(data) + } + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + self.scheduleTimeout(context: context) + context.write(data, promise: promise) + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } + + private func scheduleTimeout(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { + boundContext.value.close(promise: nil) + } + } +} + +/// A channel handler that enforces timeouts on receiving request headers and body. +/// +/// This combines header and body read timeouts into a single handler with a +/// state machine: +/// - On channel active, a header timeout is scheduled (if configured). +/// - When `.head` is received, the header timeout is cancelled and a body +/// timeout is scheduled (if configured). +/// - When `.end` is received, the body timeout is cancelled. +/// +/// If either timeout fires, the connection is closed. +final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPRequestPart + + private let readHeaderTimeout: TimeAmount? + private let readBodyTimeout: TimeAmount? + private var scheduledTimeout: Scheduled? + + init(readHeaderTimeout: TimeAmount?, readBodyTimeout: TimeAmount?) { + self.readHeaderTimeout = readHeaderTimeout + self.readBodyTimeout = readBodyTimeout + } + + func channelActive(context: ChannelHandlerContext) { + if let readHeaderTimeout { + self.scheduleTimeout(readHeaderTimeout, context: context) + } + context.fireChannelActive() + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = self.unwrapInboundIn(data) + switch part { + case .head: + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + if let readBodyTimeout { + self.scheduleTimeout(readBodyTimeout, context: context) + } + case .body: + break + case .end: + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } + context.fireChannelRead(data) + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } + + private func scheduleTimeout(_ timeout: TimeAmount, context: ChannelHandlerContext) { + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + self.scheduledTimeout = context.eventLoop.scheduleTask(in: timeout) { + boundContext.value.close(promise: nil) + } + } +} diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift new file mode 100644 index 0000000..f29a4c7 --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +@testable import NIOHTTPServer + +@Suite("Connection Backpressure Configuration") +struct ConnectionBackpressureConfigurationTests { + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections validation rejects zero") + func maxConnectionsRejectsZero() { + #expect(throws: NIOHTTPServerConfigurationError.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 0 + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections validation rejects negative") + func maxConnectionsRejectsNegative() { + #expect(throws: NIOHTTPServerConfigurationError.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: -1 + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections nil is the default") + func maxConnectionsNilIsDefault() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + #expect(config.maxConnections == nil) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("ConnectionTimeouts defaults has expected values") + func connectionTimeoutsDefaults() { + let timeouts = NIOHTTPServerConfiguration.ConnectionTimeouts.defaults + #expect(timeouts.idle == .seconds(60)) + #expect(timeouts.readHeader == .seconds(30)) + #expect(timeouts.readBody == .seconds(60)) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Valid maxConnections is accepted") + func validMaxConnectionsAccepted() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 100 + ) + #expect(config.maxConnections == 100) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Custom ConnectionTimeouts are preserved") + func customConnectionTimeouts() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + connectionTimeouts: .init(idle: .seconds(10), readHeader: .seconds(5), readBody: nil) + ) + #expect(config.connectionTimeouts.idle == .seconds(10)) + #expect(config.connectionTimeouts.readHeader == .seconds(5)) + #expect(config.connectionTimeouts.readBody == nil) + } +} + +#if Configuration +import Configuration + +@Suite("Connection Backpressure SwiftConfiguration") +struct ConnectionBackpressureSwiftConfigurationTests { + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration parses maxConnections") + func parsesMaxConnections() throws { + let provider = InMemoryProvider(values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8080, + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + "maxConnections": 500, + ]) + let config = ConfigReader(provider: provider) + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.maxConnections == 500) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration parses connectionTimeouts") + func parsesConnectionTimeouts() throws { + let provider = InMemoryProvider(values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8080, + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + "connectionTimeouts.idle": 120, + "connectionTimeouts.readHeader": 15, + "connectionTimeouts.readBody": 45, + ]) + let config = ConfigReader(provider: provider) + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.connectionTimeouts.idle == .seconds(120)) + #expect(serverConfig.connectionTimeouts.readHeader == .seconds(15)) + #expect(serverConfig.connectionTimeouts.readBody == .seconds(45)) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration uses defaults for absent fields") + func usesDefaultsForAbsentFields() throws { + let provider = InMemoryProvider(values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8080, + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ]) + let config = ConfigReader(provider: provider) + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.maxConnections == nil) + #expect(serverConfig.connectionTimeouts.idle == nil) + #expect(serverConfig.connectionTimeouts.readHeader == nil) + #expect(serverConfig.connectionTimeouts.readBody == nil) + } +} +#endif // Configuration diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift new file mode 100644 index 0000000..34330f4 --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -0,0 +1,192 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPAPIs +import Logging +import NIOCore +import NIOPosix +import Synchronization +import Testing + +@testable import NIOHTTPServer + +@Suite("Connection Backpressure End-to-End") +struct ConnectionBackpressureEndToEndTests { + let serverLogger = Logger(label: "ConnectionBackpressureE2ETests") + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Requests succeed under connection limit") + func requestsSucceedUnderConnectionLimit() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 2, + connectionTimeouts: .init(idle: nil, readHeader: nil, readBody: nil) + ) + ) + + try await confirmation(expectedCount: 2) { responseReceived in + try await NIOHTTPServerTests.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in + try await NIOHTTPServerTests.echoResponse( + readUpTo: 1024, + reader: reader, + sender: responseSender + ) + }, + body: { serverAddress in + await withThrowingTaskGroup { group in + for _ in 0..<2 { + group.addTask { + let client = try await ClientBootstrap( + group: .singletonMultiThreadedEventLoopGroup + ).connectToTestHTTP1Server(at: serverAddress) + + try await client.executeThenClose { inbound, outbound in + try await outbound.write( + .head(.init(method: .get, scheme: "http", authority: "", path: "/")) + ) + try await outbound.write(.end(nil)) + + try await NIOHTTPServerTests.validateResponse( + inbound, + expectedHead: [NIOHTTPServerTests.responseHead(status: .ok, for: .http1_1)], + expectedBody: [] + ) + + responseReceived() + } + } + } + } + } + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("More connections than maxConnections all eventually complete") + func moreConnectionsThanLimitAllComplete() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 2, + connectionTimeouts: .init(idle: nil, readHeader: nil, readBody: nil) + ) + ) + + // Open 5 connections with maxConnections: 2. All should eventually complete + // as the connection limit handler releases slots when connections close. + let numConnections = 5 + try await confirmation(expectedCount: numConnections) { responseReceived in + try await NIOHTTPServerTests.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in + try await NIOHTTPServerTests.echoResponse( + readUpTo: 1024, + reader: reader, + sender: responseSender + ) + }, + body: { serverAddress in + await withThrowingTaskGroup { group in + for _ in 0..