Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Comment thread
gjcairo marked this conversation as resolved.
)
}
}
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

after the connection/stream was open I assume. Can we please add that?

/// - `readBody` (int, optional, default: nil): Maximum time in seconds to receive the request body.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the full body? or is this between body part? please make this more explicit.

///
/// - 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
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please call out, that this is only used after the first request. before the first request, it is readHeader.


/// 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?
Comment on lines +247 to +249
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this use-full? this means that this has to account for the longest possible request. I think something like the time between body parts is more appropriate?


/// - 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

Expand All @@ -242,18 +287,31 @@ 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``.
/// - supportedHTTPVersions: The HTTP protocol versions the server should support.
/// - 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<HTTPVersion>,
transportSecurity: TransportSecurity,
backpressureStrategy: BackPressureStrategy = .defaults
backpressureStrategy: BackPressureStrategy = .defaults,
maxConnections: Int? = nil,
connectionTimeouts: ConnectionTimeouts = .defaults
Comment on lines 310 to +314
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

are we sure we want to have this initializer? it feels like we would need to extend that, whenever a new property is added? I think having the init with just first three is more appropriate. All others can be changed via properties, if needed.

) 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`.
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible {
case noSupportedHTTPVersionsSpecified
case incompatibleTransportSecurity
case invalidMaxConnections

var description: String {
switch self {
Expand All @@ -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."
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

just as a drive by notice: I think we should also support, h/2 over plaintext, shouldn't we?

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.

Yeah we're tracking that work.


case .invalidMaxConnections:
"Invalid configuration: `maxConnections` must be greater than 0."
}
}
}
66 changes: 66 additions & 0 deletions Sources/NIOHTTPServer/ConnectionLimitHandler.swift
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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`.pendingRead && `self`.activeConnections <= `self`.maxConnections {
`self`.pendingRead = false
context.read()
Copy link
Copy Markdown
Member

@fabianfett fabianfett Apr 1, 2026

Choose a reason for hiding this comment

The 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.

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.

Good point, will make this change

}
}
}

context.fireChannelRead(data)
}

func read(context: ChannelHandlerContext) {
if self.activeConnections <= self.maxConnections {
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.

Is this channel handler guaranteed to only prevent the call to read() once?

Because channel.read() is only called once upon close (line 48).

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.

I might be misunderstanding your point, but the point of this handler is not to prevent the call to read just once - it's to make sure we're always below the set limit. That might mean calling read multiple times whenever we fall below the threshold, and withhold forwards of reads when we're above it.

context.read()
} else {
self.pendingRead = true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand All @@ -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:
Expand Down Expand Up @@ -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)
}
}
```
Expand Down
8 changes: 8 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<HTTPRequestPart, HTTPResponsePart>(
wrappingChannelSynchronously: channel,
configuration: asyncChannelConfiguration
Expand Down
16 changes: 15 additions & 1 deletion Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -120,6 +126,8 @@ extension NIOHTTPServer {
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true))

try channel.addTimeoutHandlers(self.configuration.connectionTimeouts)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the call should be:

Suggested change
try channel.addTimeoutHandlers(self.configuration.connectionTimeouts)
try channel.pipeline.syncOperations.addTimeoutHandlers(self.configuration.connectionTimeouts)


return try NIOAsyncChannel<HTTPRequestPart, HTTPResponsePart>(
wrappingChannelSynchronously: channel,
configuration: .init(
Expand All @@ -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,
Expand All @@ -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<HTTPRequestPart, HTTPResponsePart>(
wrappingChannelSynchronously: http2StreamChannel,
configuration: .init(
Expand Down
35 changes: 35 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think this should be extension on the syncOperation and not the channel. This would make reading the callsite much easier.

/// 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, *)
Expand Down
Loading
Loading