Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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 @@ -73,9 +73,11 @@ extension NIOHTTPServerConfiguration {
transportSecurity: try .init(
config: snapshot.scoped(to: "transportSecurity"),
customCertificateVerificationCallback: customCertificateVerificationCallback
),
backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy"))
)
)
self.backpressureStrategy = .init(config: snapshot.scoped(to: "backpressureStrategy"))
self.maxConnections = snapshot.int(forKey: "maxConnections")
self.connectionTimeouts = .init(config: snapshot.scoped(to: "connectionTimeouts"))
}

/// Reads bind targets from either the singular `bindTarget` scope or the plural `bindTargets` scope.
Expand Down Expand Up @@ -483,4 +485,25 @@ extension CertificateVerificationMode {
}
}
}
@available(anyAppleOS 26.0, *)
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
/// after a connection is established.
/// - `readBody` (int, optional, default: nil): Maximum time in seconds to receive the complete request
/// body after headers have been received.
///
/// - 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,60 @@ 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 the connection may sit with no request in flight before being closed.
///
/// On HTTP/1.1, the timer runs between requests on a keep-alive connection: it starts
/// when the connection becomes active and is rescheduled after each response `.end` is
/// written. The timer is cancelled when an inbound request `.head` is observed.
///
/// On HTTP/2, this is delegated to `NIOHTTP2ServerConnectionManagementHandler`'s
/// `maxIdleTime`, which fires when no streams have been open for the configured duration
/// and triggers a graceful shutdown.
///
/// `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.

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.

This timeout runs from connection establishment and is reset every time something is read or written, it's independent of the readHeader timeout.

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.

okay, so after connection establishment, there are two timers: readHeader and idle? Isn't this wasteful?

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.

One is at the connection level and the other is at request level. For H1 I agree they overlap a bit, but for H2, these are pretty different things since once operates on the connection channel and the other operates at the stream channel.


/// 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 +256 to +258

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?

@gjcairo gjcairo May 6, 2026

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.

That's a fair question. Go's http package only has a single timeout from connection establishment till connection end (so, the time it takes to read everything, including headers and request body).

I think having a timeout only between body parts could open the door to attacks where a client opens a bunch of connections and slowly trickles tiny bits of data just before the timeout fires to keep the connection alive.

We could have both, but we already have the idle timeout which keeps track of all reads or writes, so it's somewhat covered. I suppose we could remove the idle timeout and instead add timeouts for the time between reads and a separate one for time between writes if we want more granularity (while also keeping this readBody timeout). What do you think?

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'm in favor of the separate read and write timeouts. Because if the client doesn't accept any writes, we are also in a bad situation.

My question remains, how do you set a reasonable readBody timeout?

Also the readBody can easily be achieved using structured concurrency. We should consider if we really need this in the api.

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.

This is btw. not a connectiontimeout but more a request timeout.

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.

Have you thought of doing a rate based check when reading the body. Expect a minimum number of bytes to have been received over a defined period of time.


/// - Parameters:
/// - idle: Maximum idle time before the connection is closed.
/// - readHeader: Maximum time to receive request headers after a connection is established.
/// - readBody: Maximum time to receive the complete request body after headers have been received.
public init(
idle: Duration? = Self.defaultIdle,
readHeader: Duration? = Self.defaultReadHeader,
readBody: Duration? = Self.defaultReadBody
Comment thread
gjcairo marked this conversation as resolved.
) {
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 specifying all addresses where the server should listen.
public var bindTargets: [BindTarget]

Expand All @@ -242,18 +296,37 @@ 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).
///
/// - Precondition: Must be greater than 0 if non-`nil`.
public var maxConnections: Int? {
didSet {
if let maxConnections, maxConnections <= 0 {
preconditionFailure("`maxConnections` must be greater than 0.")
}
}
}

/// Configuration for connection timeouts.
public var connectionTimeouts: ConnectionTimeouts

/// Create a new configuration with multiple bind targets.
///
/// Other configuration properties (``backpressureStrategy``, ``maxConnections``,
/// ``connectionTimeouts``) are initialized to their defaults and can be set on the resulting
/// value before passing it to ``NIOHTTPServer``.
///
/// - Parameters:
/// - bindTargets: An array of ``BindTarget`` values specifying where the server should listen.
/// - 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.
public init(
bindTargets: [BindTarget],
supportedHTTPVersions: Set<HTTPVersion>,
transportSecurity: TransportSecurity,
backpressureStrategy: BackPressureStrategy = .defaults
transportSecurity: TransportSecurity
) throws {
if bindTargets.isEmpty {
throw NIOHTTPServerConfigurationError.noBindTargetsSpecified
Expand All @@ -274,27 +347,30 @@ public struct NIOHTTPServerConfiguration: Sendable {
self.bindTargets = bindTargets
self.supportedHTTPVersions = supportedHTTPVersions
self.transportSecurity = transportSecurity
self.backpressureStrategy = backpressureStrategy
self.backpressureStrategy = .defaults
self.maxConnections = nil
self.connectionTimeouts = .defaults
}

/// Create a new configuration with a single bind target.
///
/// Other configuration properties (``backpressureStrategy``, ``maxConnections``,
/// ``connectionTimeouts``) are initialized to their defaults and can be set on the resulting
/// value before passing it to ``NIOHTTPServer``.
///
/// - 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.
public init(
bindTarget: BindTarget,
supportedHTTPVersions: Set<HTTPVersion>,
transportSecurity: TransportSecurity,
backpressureStrategy: BackPressureStrategy = .defaults
transportSecurity: TransportSecurity
) throws {
try self.init(
bindTargets: [bindTarget],
supportedHTTPVersions: supportedHTTPVersions,
transportSecurity: transportSecurity,
backpressureStrategy: backpressureStrategy
transportSecurity: transportSecurity
)
}
}
Expand Down
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.

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.

The child channel's EL may be different from the parent's, that's why. I tried removing this anyways to confirm and saw a crash.

let `self` = loopBoundSelf.value
let context = loopBoundContext.value
`self`.activeConnections -= 1
if `self`.pendingRead && `self`.activeConnections <= `self`.maxConnections {
`self`.pendingRead = false
context.read()
Comment thread
fabianfett marked this conversation as resolved.
}
}
}

context.fireChannelRead(data)
}

func read(context: ChannelHandlerContext) {
if self.activeConnections <= self.maxConnections {
Comment thread
aryan-25 marked this conversation as resolved.
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: Exactly one of `bindTarget` (singular, for a single address) or `bindTargets` (plural, for multiple
> addresses) must be provided. Providing both results in an error.
Expand Down Expand Up @@ -62,6 +62,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 @@ -113,6 +117,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
11 changes: 11 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ extension NIOHTTPServer {
try channel.pipeline.syncOperations.addHandler(
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
self.setupHTTP1_1Connection(
Expand Down Expand Up @@ -131,6 +137,11 @@ extension NIOHTTPServer {
try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: isSecure))
try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler())

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

return try NIOAsyncChannel<HTTPRequestPart, HTTPResponsePart>(
wrappingChannelSynchronously: channel,
configuration: asyncChannelConfiguration
Expand Down
14 changes: 13 additions & 1 deletion Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ extension NIOHTTPServer {
try channel.pipeline.syncOperations.addHandler(
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
self.setupSecureUpgradeConnectionChildChannel(
Expand Down Expand Up @@ -249,7 +255,7 @@ extension NIOHTTPServer {
try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline(
mode: .server,
connectionManagerConfiguration: .init(
maxIdleTime: nil,
maxIdleTime: self.configuration.connectionTimeouts.idle.map { TimeAmount($0) },
maxAge: nil,
maxGraceTime: configuration.gracefulShutdown.maximumGracefulShutdownDuration
.map { TimeAmount($0) },
Expand All @@ -263,6 +269,12 @@ extension NIOHTTPServer {
HTTP2FramePayloadToHTTPServerCodec()
)

// Add read header and body timeouts per-stream for HTTP/2
try http2StreamChannel
.pipeline
.syncOperations
.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 @@ -329,6 +329,41 @@ public struct NIOHTTPServer: HTTPServer {
}
}
}

}

@available(anyAppleOS 26.0, *)
extension ChannelPipeline.SynchronousOperations {
/// Adds timeout handlers (idle, read header, read body) to the channel pipeline.
///
/// Only handlers for non-nil timeouts are installed. Called for HTTP/1.1 connection channels.
func addTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws {
try self.addIdleTimeoutHandlers(timeouts)
try self.addReadTimeoutHandlers(timeouts)
}

/// Adds the connection idle timeout handler to the channel. Used by HTTP/1.1 connection
/// channels. (HTTP/2 delegates idle handling to `NIOHTTP2ServerConnectionManagementHandler`'s
/// `maxIdleTime`, which is stream-aware.)
func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws {
if let idle = timeouts.idle {
try self.addHandler(
ConnectionIdleTimeoutHandler(timeout: TimeAmount(idle))
)
}
}

/// Adds only read header and body timeout handlers to the channel. Used for HTTP/1.1
/// connection channels and HTTP/2 per-stream channels.
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.addHandler(
RequestTimeoutHandler(readHeaderTimeout: readHeader, readBodyTimeout: readBody)
)
}
}
}

@available(anyAppleOS 26.0, *)
Expand Down
Loading
Loading