Changes since 1.0.0-alpha56. This release reworks the server core. HTTP/1.1, HTTP/2 and HTTP/3 now share one request and response policy and one timeout model, options are validated, WebSocket support is much more complete, and cleartext HTTP/2, Ring async handlers and event hooks are new. Many options were renamed, so read the breaking changes first.
ex-info with a "did you mean" suggestion. A wrong type or invalid value throws ex-info whose ex-data holds :type, :option and :value. Previously unknown keys were ignored and invalid values threw IllegalArgumentException, so code catching that from run-server must catch ExceptionInfo instead. A nil value means the default.-timeout and are in milliseconds, sizes end in -bytes, TLS options start with :ssl-. See the table below for renames.:handshake-timeout, :header-timeout, :read-timeout, :write-timeout, :idle-timeout and :handler-timeout, each with one meaning on every protocol (doc/options.md).
:request-timeout is removed. On HTTP/1.1 it bounded reading the whole request; that is now :header-timeout (10 s for the head, from its first byte) plus :read-timeout (30 s without body progress). On HTTP/2 and HTTP/3 it bounded the handler and answered 408. Handlers now run unbounded by default, and :handler-timeout (off by default) answers 503.:idle-timeout changes meaning and default. It was a per-read socket timeout (30 s). It is now how long a connection may sit with no request in progress (75 s): HTTP/1.1 between requests, HTTP/2 and HTTP/3 with no open stream, a WebSocket with no frame from the client.:keep-alive-timeout is removed, folded into :idle-timeout.:http3-max-idle-timeout is removed. QUIC's max_idle_timeout is :idle-timeout, so its default goes from 30 s to 75 s.:http2-max-header-list-size (8192) and :http3-max-field-section-size are removed, and :max-header-bytes (65536) applies to HTTP/2 and HTTP/3 too: 431 per request, and the connection only ends above 4× that (at least 64 KiB).:request-buffer-size, :max-inline-body, :coalesce-high-water, :chunk-buffer-size, :max-drain-bytes (HTTP/1.1 buffer tuning), :http3-qpack-max-table-capacity, :http3-qpack-blocked-streams (QPACK uses the static table only) and :worker-executor (handlers always run on virtual threads).:http3-initial-max-data-bytes: 1 GiB → 512 KiB. It is now the window a connection starts with (it grows up to twice :http3-max-window-bytes as :max-buffered-bytes pays, with the release builds' libquiche) and caps the request body bytes buffered per connection. It is charged to :max-buffered-bytes while the connection reads a request body. HTTP/3 per-stream windows default to half of it (256 KiB), so one stream whose handler doesn't read can't stall the connection's other requests.:http2-initial-window-bytes: 1 MiB → 256 KiB, now where windows start. A stream window doubles up to :http2-max-window-bytes (8 MiB) while its handler keeps up, and the connection window (4× the initial one, 1 MiB) up to twice that, as far as :max-buffered-bytes pays.:http2-initial-window-bytes must be at least 65535.:max-request-body-bytes no longer applies to WebSocket frames; use :ws-max-message-bytes.:ssl-alpn-protocols defaults to ["http/1.1"] without :http2. It used to be the JVM default.:ssl-alpn-protocols, :ssl-cipher-suites, :ssl-protocols and client auth need TLS, and offering "h2" needs :http2. :http3-port and :advertise-alt-svc true need :http3. :http3 with :port 0 needs :http3-port (or :advertise-alt-svc false). :server-header must be a valid field value. :http2 needs TLS; cleartext HTTP/2 is :http2c.| alpha56 option | Now |
|---|---|
:idle-timeout (30000, per read) | :idle-timeout (75000, connection idle); per-read bounds are :read-timeout and :write-timeout |
:request-timeout | removed: :header-timeout + :read-timeout (HTTP/1.1), :handler-timeout (HTTP/2, HTTP/3; 503, off by default) |
:keep-alive-timeout | removed (:idle-timeout) |
:so-rcv-buf | :so-rcv-buf-bytes |
:so-snd-buf | :so-snd-buf-bytes |
:alpn-protocols | :ssl-alpn-protocols |
:enabled-cipher-suites | :ssl-cipher-suites |
:enabled-tls-protocols | :ssl-protocols |
:http2-initial-window-size | :http2-initial-window-bytes |
:http2-max-frame-size | :http2-max-frame-bytes |
:http2-max-header-list-size | removed (:max-header-bytes) |
:http3-max-idle-timeout | removed (:idle-timeout) |
:http3-initial-max-data (1 GiB) | :http3-initial-max-data-bytes (512 KiB) |
:http3-max-udp-payload-size | :http3-max-udp-payload-bytes |
:http3-initial-max-stream-data-bidi-local | :http3-initial-max-stream-data-bidi-local-bytes |
:http3-initial-max-stream-data-bidi-remote | :http3-initial-max-stream-data-bidi-remote-bytes |
:http3-initial-max-stream-data-uni | :http3-initial-max-stream-data-uni-bytes |
:http3-max-field-section-size | removed (:max-header-bytes) |
:http3-qpack-max-table-capacity | removed |
:http3-qpack-blocked-streams | removed |
:request-buffer-size, :max-inline-body, :coalesce-high-water, :chunk-buffer-size, :max-drain-bytes | removed |
:worker-executor | removed |
Unchanged: :port :host :backlog :shutdown-timeout
:max-header-bytes :max-request-body-bytes :max-keep-alive-requests
:so-nodelay :so-reuse-addr :so-linger :ssl-context
:ssl-context-provider :ssl-need-client-auth :ssl-want-client-auth
:ssl-session-cache-size :http2 :http2-max-concurrent-streams
:http2-stream-reset-limit :http2-continuation-limit :http3
:http3-port :http3-cert-path :http3-key-path
:http3-initial-max-streams-bidi :http3-initial-max-streams-uni
:http3-stateless-retry :http3-ack-delay-exponent :http3-max-ack-delay
:http3-active-connection-id-limit :advertise-alt-svc :alt-svc-max-age
:server-header :error-handler.
WebSocketListener and WebSocketSocket moved from com.s_exp.enso.websocket to com.s_exp.enso.api. WebSocketSocket gained sendTextAsync, sendBinaryAsync and SendCallback. New api.WebSocketException, with code().s-exp.enso namespace, EnsoServer and the com.s_exp.enso.api package. Every other package is internal.Config.Builder setters follow the option names (soRcvBuf → soRcvBufBytes, alpnProtocols → sslAlpnProtocols, http2InitialWindowSize → http2InitialWindowBytes, http3InitialMaxData → http3InitialMaxDataBytes, ...), and setters of removed options are gone. build() throws Config.InvalidOptionException, an IllegalArgumentException with option() and value().EnsoServer constructors no longer throw IOException; start() does. register and unregister are removed.ChunkedWriter.write declares IOException, since it may flush. closeInternal() is removed; charset(Charset) and bytesWritten() are added.RingErrorHandler.respond takes a fourth argument, expected: whether the failure is expected (I/O, or the interrupt that follows the server giving up on the exchange), which is logged at FINE instead of WARNING.:body is nil when the request has no body, on every protocol. HTTP/2 and HTTP/3 used to always pass an InputStream.:scheme is :https on TLS connections. It was always :http.:server-name without a Host header is the local address. It was "localhost".:remote-addr writes IPv6 in the RFC 5952 short form.These apply to every protocol. A violation is a 500, or :error-handler.
:status must be an integer in 200-599, or 101 for a WebSocket upgrade. A :status that is present but nil or not an integer is a handler error. A missing :status still means 200.Transfer-Encoding is dropped, since the server frames the body. A handler Content-Length is dropped when the server knows the length. Two different Content-Length values are an error.Content-Type charset, UTF-8 when there is none. An unknown charset is a 500. alpha56 always used UTF-8.:max-connections (10000) and :max-connections-per-ip (0): connection caps, TCP and QUIC together.:handshake-timeout (10000), :header-timeout (10000), :read-timeout (30000), :write-timeout (30000), :handler-timeout (0).:max-header-fields (100): request header fields per head, 431 above.:max-buffered-bytes (0, meaning a quarter of the heap): a server-wide budget for bytes buffered on behalf of peers and the flow-control credit promised to them, enforced by back-pressure with a fair share per connection.:min-data-rate-bytes (240) and :min-data-rate-grace (5000): the slowest request body accepted while a handler waits for it (Kestrel's defaults). Slower bodies fail with 408 ("min-data-rate").:http2c (false): cleartext HTTP/2 with prior knowledge, next to HTTP/1.1.:http2-max-window-bytes (8388608): the largest an HTTP/2 stream's receive window autotunes to. The connection window grows to twice this.:http3-max-window-bytes (8388608): the same for HTTP/3. Needs the release builds' patched libquiche; ignored with a stock one.:http3-cert-reload-interval (10000): HTTP/3 certificate rotation.:http3-retry-threshold (256), :http3-max-half-open (1024) and :http3-stream-reset-limit (400): HTTP/3 flood protection.:http3-event-loops (0, meaning one per core on Linux and one elsewhere), :http3-so-rcv-buf-bytes and :http3-so-snd-buf-bytes (4194304).:ws-max-message-bytes (1048576), :ws-max-queued-bytes (1048576), :ws-close-timeout (5000), :ws-compression (false), :ws-ping-interval (0) and :ws-allowed-origins (same origin).:async (false): Ring asynchronous handlers.:server-events (nil): connection, request and protocol-error callbacks, delivered in order from their own thread, never a server thread. A listener that falls 8192 events behind loses the next ones, counted by EnsoServer.droppedEvents().:async true. raise goes to :error-handler, and :handler-timeout applies. A handler that throws before respond or raise has raised; one that throws after is logged and its response stands.StreamableResponseBody writes to refuses writes once closed.:http2c) with prior knowledge on the plain listener, next to HTTP/1.1. No Upgrade: h2c. Reported as protocol "h2c".:http2-initial-window-bytes up to :http2-max-window-bytes, and the connection's up to twice that. Every growth of the connection window is paid for from :max-buffered-bytes first, nothing grows while the connection is throttled, the connection window returns to its baseline once no request body is open, and no credit is granted past what bodies with a Content-Length have left to send.:http3-max-window-bytes, and the connection's up to twice that. Every doubling of the connection window is paid for from :max-buffered-bytes first and skipped while the connection is throttled. Release shims link libquiche with patches that add receive-window control on a live connection and bound the out-of-order fragments a stream buffers (native/enso_quiche/patches/). With a stock libquiche (development builds) windows keep their initial sizes and fragments are unbounded.:max-buffered-bytes. An idle QUIC connection buffers no request body, so an exhausted budget no longer drops new Initials; :max-connections, :max-connections-per-ip and :http3-max-half-open bound how many connections are accepted.:max-connections, :max-connections-per-ip or :http3-max-half-open turns a client away, its Initial is answered with a stateless CONNECTION_CLOSE carrying CONNECTION_REFUSED at the Initial level, so the client falls back at once instead of waiting out a handshake timeout.SO_REUSEPORT with BPF steering), and a failed loop is restarted.:error-handler works on HTTP/2 and HTTP/3. It was HTTP/1.1 only.Expect: 100-continue is supported on every protocol, answered on the handler's first body read. Any other expectation gets 417 without running the handler.:max-request-body-bytes without running the handler, 431 for heads over :max-header-bytes or :max-header-fields.text/plain; charset=utf-8 reason phrase.:error-handler isn't called.write! and seq bodies encode Strings with the response charset.:shutdown-timeout is one deadline for the whole server, HTTP/2 and HTTP/3 drain in two GOAWAYs, and streams in flight when a peer sends GOAWAY finish.ring.websocket.protocols support (Listener, PingListener, Socket, AsyncSocket), asynchronous sends with back-pressure, permessage-deflate, server pings, idle close with 1001, CLOSE 1001 on server stop. A listener exception closes with 1011 and its message stays on the server. Handler :headers are sent on the 101. A WebSocket response on HTTP/2 or HTTP/3 is answered 501.api.ServerEvents (connectionOpened, connectionClosed, requestCompleted, protocolError) for HTTP/1.1, h2, h2c, h3, WebSocket, TCP and TLS, and the JFR events com.s_exp.enso.Connection, Request and ProtocolError on every protocol.:handler-timeout) logs the resulting InterruptedException at FINE. A streamed body that fails for another reason logs at WARNING on HTTP/1.1 and HTTP/3 too.:max-buffered-bytes limits growth, never progress. Each connection keeps a floor it can always use (HTTP/2's initial 65535-octet window, 64 KiB of buffered body per HTTP/3 connection, 64 KiB of message buffer per WebSocket), so an upload on a connection under its floor goes on while the budget is exhausted. WebSocket messages are delivered whole, so one WebSocket at a time reads past the limit until its message is delivered, and the others take turns. Messages that together, or alone, need more than the budget no longer stall until their deadline.:read-timeout and :min-data-rate-bytes only count time the client could have sent. While the server holds a request body back (an HTTP/2 client out of connection credit the server withholds, an HTTP/3 stream the server doesn't read, an HTTP/3 client out of connection credit because unread streams fill its window) they stop, so the client no longer gets a 408 for a stall the server caused. A WebSocket message's :read-timeout stops the same way while the server doesn't read it for the budget, so no false 1008.EnsoServer.isHealthy() tells whether they all run.#'handler).:max-header-bytes is 414 ("uri-too-long"), and a bare LF line ending is 400 at once instead of waiting out :header-timeout.Connection field without "close" is left out. A handler throwing HttpError with a status outside 4xx and 5xx gets 500.wrap-head) carries no Content-Length, on every protocol, since nil says nothing about GET's length.requestCompleted fires for every HTTP/1.1 response, including a failed write or a 101 flush. The 101 after a keep-alive response reports its own body bytes (none).HTTP/1.1:
:header-timeout and the :min-data-rate-bytes allowance are now wall clock over TLS too.:write-timeout.:handler-timeout interrupt never lands in a socket read, where it closed the socket and lost the lingering close that protects the 503 from a reset. A late expiry can't claim the next request on the connection.HTTP/2:
:write-timeout. Tiny connection WINDOW_UPDATEs no longer reschedule streams waiting on their own window.:max-buffered-bytes. Connection credit beyond the initial 65535 octets is paid for from it before it is granted, and the connection window only grows once body bytes arrive, shared fairly between connections under pressure. Budget wake-ups run off the releasing thread, since an inline wake-up could deadlock two connections. The default stream window is 256 KiB (1 MiB per connection, as in Go and Kestrel).:max-request-body-bytes ends the stream. :idle-timeout counts from the end of the last response.ByteArrayInputStream bodies cost what a byte array does (about 21 KB less per response), and streamed bodies use pooled, right-sized rings.All protocols:
:min-data-rate-bytes fail with 408, so one byte every 29 s no longer holds a request forever.:write-timeout, as on HTTP/2.HTTP/3 and QUIC:
:http3-retry-threshold, and half-open connections are capped. With :max-connections-per-ip set, clients prove their address with a Retry before taking a per-address slot.:http3-stream-reset-limit. Unread request bodies are bounded per connection.te other than trailers (H3_MESSAGE_ERROR), the same rule HTTP/2 applies.:header-timeout.:max-connections.:max-buffered-bytes.QUIC interoperability:
WebSocket:
:ws-allowed-origins is a hard gate: a missing or opaque Origin is refused even when the list is ["*"].:read-timeout.:max-buffered-bytes by capacity, a buffer kept for the next message included. Compressed messages count as they inflate, not as their compressed bytes arrive.Upgrade is read as a token list, like Connection (Upgrade: h2c, websocket).Core and TLS:
:handshake-timeout and :idle-timeout may not both be 0 (that would leave a half-open connection unbounded); the server refuses to start.Native loader:
Current numbers from the separate-process harness (clojure -M:perf,
Apple M1 Pro laptop, 8 CPUs, JDK 25, median of three runs; see
doc/performance.md):
| Scenario | req/s | Allocated per request (whole server JVM) |
|---|---|---|
| HTTP/1.1 GET, 64 connections | 123,830 | 393 B |
| HTTP/1.1 pipelined, depth 16 | 1,384,895 | 235 B |
HTTP/2 GET over TLS (h2load -c 8 -m 32) | 745,127 | 1,090 B |
| HTTP/3 GET, 4 connections × 32 in flight | 73,221 | 801 B |
| WebSocket echo, 128-byte messages | 1,023,481 | 333 B |
Measured during this rework, each against the code before the change:
:remote-addr, :server-name and :server-port from 376 to 240 (derived once per connection).darwin-arm64, linux-amd64, linux-arm64, linux-musl-amd64, linux-musl-arm64) on Clojars. The all-platform jar is a release-run artifact only.Automatic-Module-Name: com.s_exp.enso, LICENSE, NOTICE and THIRD-PARTY-NOTICES.--enable-native-access=ALL-UNNAMED for HTTP/3.enso.quiche.tmpdir or ENSO_QUICHE_TMPDIR, for noexec temp directories.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |