Add connection backpressure and timeouts#72
Conversation
| func addTimeoutHandlers(to channel: any Channel) throws { | ||
| let timeouts = self.configuration.connectionTimeouts | ||
|
|
||
| if let idle = timeouts.idle { |
There was a problem hiding this comment.
Looks like these three if blocks are identical to the method bodies below. We can probably call into those methods here.
| if let idle = self.configuration.connectionTimeouts.idle { | ||
| let idleTimeAmount = TimeAmount(idle) | ||
| try channel.pipeline.syncOperations.addHandler( | ||
| IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) |
There was a problem hiding this comment.
Should we allow both the read and write idle timeouts to be configurable?
| try await NIOHTTPServerTests.withServer( | ||
| server: server, | ||
| serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in | ||
| _ = try await reader.consumeAndConclude { bodyReader in |
There was a problem hiding this comment.
We can use NIOHTTPServerTests.echoResponse here.
| ) | ||
| try await outbound.write(.end(nil)) | ||
|
|
||
| var iter = inbound.makeAsyncIterator() |
There was a problem hiding this comment.
We can use NIOHTTPServerTests.validateResponse here.
| } | ||
|
|
||
| func read(context: ChannelHandlerContext) { | ||
| if self.activeConnections <= self.maxConnections { |
There was a problem hiding this comment.
Is this channel handler guaranteed to only prevent the call to read() once?
Because channel.read() is only called once upon close (line 48).
There was a problem hiding this comment.
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.
| let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) | ||
| let eventLoop = context.eventLoop | ||
| childChannel.closeFuture.whenComplete { _ in | ||
| eventLoop.execute { |
There was a problem hiding this comment.
why do we need to hop here? we already should be on the correct EL.
| /// | ||
| /// The timeout starts when the channel becomes active and is cancelled when | ||
| /// a `.head` part is received. | ||
| final class ReadHeaderTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { |
There was a problem hiding this comment.
While it is totally fair to implement this in additional ChannelHanders, I strongly advise against it for performance reasons. We should really try to reduce pipeline overhead and pipeline modifications.
There was a problem hiding this comment.
I've consolidated these into two handlers instead. Is that what you had in mind?
| /// ## 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. |
There was a problem hiding this comment.
the full body? or is this between body part? please make this more explicit.
| /// Maximum time allowed to receive the complete request body | ||
| /// after headers have been received. `nil` means no timeout. | ||
| public var readBody: Duration? |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is btw. not a connectiontimeout but more a request timeout.
There was a problem hiding this comment.
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.
| 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? |
There was a problem hiding this comment.
please call out, that this is only used after the first request. before the first request, it is readHeader.
There was a problem hiding this comment.
This timeout runs from connection establishment and is reset every time something is read or written, it's independent of the readHeader timeout.
There was a problem hiding this comment.
okay, so after connection establishment, there are two timers: readHeader and idle? Isn't this wasteful?
| supportedHTTPVersions: Set<HTTPVersion>, | ||
| transportSecurity: TransportSecurity, | ||
| backpressureStrategy: BackPressureStrategy = .defaults | ||
| backpressureStrategy: BackPressureStrategy = .defaults, | ||
| maxConnections: Int? = nil, | ||
| connectionTimeouts: ConnectionTimeouts = .defaults |
There was a problem hiding this comment.
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.
| @@ -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." | |||
There was a problem hiding this comment.
just as a drive by notice: I think we should also support, h/2 over plaintext, shouldn't we?
There was a problem hiding this comment.
Yeah we're tracking that work.
| /// - When `.end` is received, the body timeout is cancelled. | ||
| /// | ||
| /// If either timeout fires, the connection is closed. | ||
| final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { |
There was a problem hiding this comment.
we 1000% should combine those two handlers into just one. We should try to reduce the total number of ChannelHandlers as much as possible.
There was a problem hiding this comment.
I did it this way because for H2, we only want the idle handler in the connection channel, and the read handler in the stream channel, and merging them made it more confusing to read IMO as it would mean passing nil timeouts depending on which channel we're adding the handler to. I understand merging them would improve the performance of H1 requests though since we'd be adding a single handler instead of 2. I can make the change if we want to sacrifice some readability for performance in that case though.
There was a problem hiding this comment.
I can make the change if we want to sacrifice some readability for performance in that case though.
This would mean that we currently also translate H2 to H1 to add timeouts. I think we need specialized handlers here.
| 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? |
There was a problem hiding this comment.
okay, so after connection establishment, there are two timers: readHeader and idle? Isn't this wasteful?
| /// Maximum time allowed to receive the complete request body | ||
| /// after headers have been received. `nil` means no timeout. | ||
| public var readBody: Duration? |
There was a problem hiding this comment.
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.
| /// Maximum time allowed to receive the complete request body | ||
| /// after headers have been received. `nil` means no timeout. | ||
| public var readBody: Duration? |
There was a problem hiding this comment.
This is btw. not a connectiontimeout but more a request timeout.
| idle: Duration? = Self.defaultIdle, | ||
| readHeader: Duration? = Self.defaultReadHeader, | ||
| readBody: Duration? = Self.defaultReadBody |
There was a problem hiding this comment.
if we add another timeout we have to add another initializer. Why not just add a .init() and users can set values using the vars?
| let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) | ||
| self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { |
There was a problem hiding this comment.
I think we should use assumeIsolated() here over NIOLoopBound. This allows us to only check the correct EL once. When calling assumeIsolated()
| /// - When `.end` is received, the body timeout is cancelled. | ||
| /// | ||
| /// If either timeout fires, the connection is closed. | ||
| final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { |
There was a problem hiding this comment.
I can make the change if we want to sacrifice some readability for performance in that case though.
This would mean that we currently also translate H2 to H1 to add timeouts. I think we need specialized handlers here.
This PR adds connection-level backpressure and timeout support to the server.
A new optional
maxConnectionsfield onNIOHTTPServerConfigurationlets users cap the number of concurrent connections: when the limit is reached, the server stops accepting new connections by gating NIO read events on the server channel until existing connections close.Alongside this, a new
ConnectionTimeoutsconfiguration provides idle, read-header, and-read body timeouts to evict slow or misbehaving connections, to mitigate attacks against the connection limit. Timeouts are enabled by default with reasonable values (60s/30s/60s respectively); the connection limit is opt-in.Both features work across HTTP/1.1 and HTTP/2, with timeout handlers split appropriately between connection and stream levels for HTTP/2.