blackbull.env¶
blackbull.env
¶
Runtime configuration sourced from environment variables.
All server settings live in :class:Settings. Retrieve the current
configuration with :func:get_settings, which reads environment variables
once and returns an immutable snapshot.
Environment variables¶
BLACKBULL_ENV
production | development | test. An unrecognised value falls
back to development.
Default: development.
BB_WORKERS
Number of worker processes. 0 resolves to os.cpu_count().
Default: 1.
BB_WORKER_DRAIN_TIMEOUT
Seconds a worker lets already-accepted connections finish after SIGTERM
before cancelling what is left. Sits inside the supervisor's own wait,
so raising it past that wait moves the deadline without extending it.
0 drops in-flight requests immediately.
Default: 8.0.
BB_MAX_CONNECTIONS
Maximum simultaneous TCP connections accepted per worker. When the
cap is reached, new connections receive HTTP/1.1 503 Service
Unavailable with Retry-After: 1 (a load-balancer-friendly
response, not a silent reset).
Accepts auto (the default), 0 to disable the cap, or an
explicit number.
auto derives the cap from this process's own RLIMIT_NOFILE,
less a small reserve for listeners, the event loop's selector, log
files and the application's own descriptors. A cap above the fd
budget would be decorative — accept() fails with EMFILE before
the cap is consulted, and the peer gets a dropped connection instead
of the 503 — so the derived value can only refuse connections the OS
was going to refuse anyway. That is what makes a finite default safe
to ship, and it follows the operator's own intent: raising the fd
limit is how you say how large this process may become. The resolved
value is logged at startup, because a derived default nobody can see
is a default nobody can size.
An explicit number is honoured as given, not clamped to the fd budget.
Note this bounds descriptor exhaustion, not event-loop health: a
ceiling reflecting what one asyncio loop serves well is a policy
number that depends on the workload — set it explicitly; 1024 is a
typical single-loop value. Multi-worker deployments multiply (so
workers=8 × BB_MAX_CONNECTIONS=1024 → 8K per process).
Default: auto.
BB_STREAM_QUEUE_DEPTH
asyncio.Queue depth for HTTP/2 per-stream request-body events.
Limits memory growth when an ASGI handler is slower than the client.
Default: 64.
BB_WS_QUEUE_DEPTH
WebSocket inbound read-ahead depth. 0 reads frames inline in the
handler's own task — no reader task and no per-message queue hop. A
positive value runs a background reader that reads ahead of the
handler into an asyncio.Queue of that depth, which buys
control-frame servicing between the handler's receive() calls.
Default: 0.
BB_ASYNC_LOGGING
1 | true | yes to enable; 0 | false | no to disable.
When enabled, a QueueHandler is installed on the blackbull logger
so that logger.debug/info calls in the event loop are non-blocking.
Default: true.
BB_ACCESS_LOG
1 | true | yes to enable; 0 | false | no to disable.
When disabled, the blackbull.access logger is silenced (level set to
WARNING) so no access log records are formatted or emitted. Useful in
production where a separate log aggregator consumes structured logs and the
per-request overhead of the access logger is undesirable.
Default: true.
BB_LOG_FORMAT
Async-logging sink format. json emits one structured JSON object per
line; anything else (default) keeps plain text.
Default: (plain).
BB_SYSLOG_ADDR
host:port of a syslog/UDP collector (e.g. 127.0.0.1:514). When set,
the async-logging sink ships records via a UDP SysLogHandler instead of
stderr. Composes with BB_LOG_FORMAT=json.
Default: (stderr sink).
BB_LOG_FILE
Path the async-logging sink appends to instead of stderr. Each
worker opens its own append stream post-fork, so no writer thread is
inherited across fork(). Composes with BB_LOG_FORMAT and
BB_LOG_BATCH_SIZE; ignored for the syslog sink. An unopenable path
falls back to stderr with a warning.
Default: (stderr sink).
BB_LOG_BATCH_SIZE
Coalescing width of the stderr/file async-logging sink: up to this
many formatted lines are joined into a single write(). Async
logging is batch logging — the sink always coalesces (floored at 2),
because a per-record flush is the dominant access-log cost. To get one
write per record, disable async logging (BB_ASYNC_LOGGING=0, the
synchronous path) rather than lowering this. Ignored for the syslog
sink.
Default: 64.
BB_LOG_BATCH_TIMEOUT_MS
Max milliseconds a partial log batch waits before flush. Only meaningful
when BB_LOG_BATCH_SIZE > 1.
Default: 5.
BB_SOCKET_BACKLOG
listen() backlog depth for the server socket. Increasing this reduces
silent connection drops during burst traffic when the accept loop falls
behind. Capped by net.core.somaxconn on Linux.
Default: 1024 — sized for servers facing connection bursts, since
128 (the traditional SOMAXCONN) is shallow next to nginx's and
Node's 511. Bump to 4096 for production traffic — see
docs/reference/env-vars.md "Performance recommendations".
BB_SOCKET_SNDBUF
Kernel send-buffer size (bytes) set on each accepted TCP socket via
SO_SNDBUF. The kernel doubles the requested value internally.
Larger values improve throughput for large responses (≥64 kB).
0 leaves the kernel default unchanged.
Default: 0 (kernel default). 262144 (256 kB) is a common
production value — see docs/reference/env-vars.md.
BB_SOCKET_RCVBUF
Kernel receive-buffer size (bytes) set on each accepted TCP socket via
SO_RCVBUF. Same doubling rule as BB_SOCKET_SNDBUF.
0 leaves the kernel default unchanged.
Default: 0 (kernel default). 262144 (256 kB) is a common
production value — see docs/reference/env-vars.md.
BB_SOCKET_REUSEPORT
1 | true | yes to enable; 0 | false | no to disable.
When enabled and the OS supports SO_REUSEPORT, each worker binds its own
listening socket so the kernel distributes incoming connections across workers
independently, eliminating thundering-herd and improving CPU affinity.
Has no effect with a single worker or on platforms without SO_REUSEPORT.
Default: false (kernel default). Enable on multi-worker production
deployments — see docs/reference/env-vars.md.
BB_KEEP_ALIVE_TIMEOUT
Idle timeout (seconds) on a keep-alive HTTP/1.1 connection that is
awaiting the next request. Application-level timer; same
ghost-eviction guarantee as SO_KEEPALIVE without the per-accept
syscall cost. 0 disables the timer. Default: 5.0.
BB_TCP_USER_TIMEOUT_MS
TCP_USER_TIMEOUT value in milliseconds for accepted sockets
(Linux only). Forces a connection-level error if a peer fails to
ACK in this window — defends against dead-mid-write peers that
SO_KEEPALIVE misses. 0 leaves the kernel default
unchanged. Default: 0.
BB_HEADER_TIMEOUT
Maximum seconds the server will wait for a complete HTTP/1.1
request-header block (request-line + headers + CRLFCRLF). Primary
slowloris defence. When the deadline elapses the server returns
408 Request Timeout and closes. 0 disables.
Default: 10.0.
BB_BODY_TIMEOUT
Maximum seconds for the HTTP/1.1 request body to arrive once headers
are parsed. Mirrors BB_HEADER_TIMEOUT for the body half;
defeats slowloris-style Content-Length: N connections that drip
body bytes after the headers have arrived. 0 disables.
Default: 30.0.
BB_WRITE_TIMEOUT
Maximum seconds the server will wait for a single write to flush to
the peer (via StreamWriter.drain()). Defends against the
slow-read shape of slowloris: a client that reads 1 byte/sec
eventually fills the kernel send buffer and drain() would block
indefinitely. 0 disables. Default: 30.0.
BB_REQUEST_TIMEOUT
Maximum seconds a single request handler is allowed to run. Applied on
both protocols: HTTP/2 cancels the stream with RST_STREAM CANCEL; HTTP/1.1
emits 408 Request Timeout with Connection: close and closes the
connection (no keep-alive across a timed-out request). Prevents slow or
stalled handlers from holding stream / connection slots indefinitely.
0 disables the timeout. Default: 0 (disabled).
BB_HEADER_MAX_LINE
Maximum bytes in a single HTTP/1.1 request-line or header line.
Enforced before parsing so an attacker cannot exhaust memory with a
pathological 1 GB header. Default: 8192 (matches Apache
LimitRequestLine / nginx large_client_header_buffers).
BB_HEADER_MAX_TOTAL
Maximum total bytes in the entire HTTP/1.1 request header block
(request-line + all headers + CRLFCRLF). Default: 65536
(matches typical reverse-proxy defaults).
BB_BODY_CHUNK_SIZE
Slice size (bytes) for streaming an HTTP/1.1 Content-Length request
body to the ASGI app as successive http.request events instead of one
giant allocation. Default: 65536 (asyncio's StreamReader buffer).
Must be > 0.
BB_BODY_CHUNK_MAX
Upper bound (bytes) on one such read. Reads are up-to-n and
transport-paced, so a slow peer yields small slices while a fast one
earns fewer, larger ones; this caps what a single read may materialise
per connection. Bounds the Content-Length framing only —
BB_BODY_CHUNK_SIZE is the chunked path's slice, and the two are
never compared against each other. 0 is raised to 1, since an
up-to-zero read returns b'' and would be indistinguishable from EOF.
Default: 524288 (512 KiB).
BB_MAX_BODY_SIZE
Maximum total request-body octets accepted for one request — the total
that BB_BODY_CHUNK_MAX, a per-read bound, does not own. Over the cap
the server answers 413 Content Too Large and closes: a declared
Content-Length is refused at head time, a chunked body the moment
the running total passes it. The connection always closes on a refusal,
since parsing attacker-chosen unread octets as the next request is the
request-smuggling shape. 0 disables the cap.
Default: 31457280 (30 MiB) — the same class as Kestrel's
MaxRequestBodySize; nginx defaults to 1 MB, axum to 2 MB.
BB_MIN_BODY_RATE
Minimum sustained request-body delivery rate in bytes per second,
averaged over a sliding window one grace period wide. A transport-paced
read cannot be the anti-trickle bound: BB_BODY_TIMEOUT degrades to
"send something every N seconds", which a one-byte drip satisfies, and
a rate is what a drip cannot fake. 0 disables the detector.
Default: 240.0 (with the 5 s grace, Kestrel's
MinRequestBodyDataRate defaults).
BB_MIN_BODY_RATE_GRACE
Seconds of body-read waiting before BB_MIN_BODY_RATE is enforced, so
a connection is never judged on its first few packets. Only time spent
waiting on the transport counts, never time the handler spent between
reads.
Default: 5.0.
BB_H2_INITIAL_WINDOW_SIZE
Per-stream flow-control window size (bytes) advertised to HTTP/2 peers in the
server's initial SETTINGS frame. Larger values allow peers to send more data
per stream before waiting for WINDOW_UPDATE.
Default: 65535 (RFC 9113 §6.9.2 default). 1048576 (1 MiB) is a
common tuned value for upload-heavy or multiplexed workloads — see
docs/reference/env-vars.md.
BB_H2_CONNECTION_WINDOW_SIZE
Connection-level flow-control window size (bytes) advertised to HTTP/2 peers
via an initial WINDOW_UPDATE on stream 0 after the SETTINGS handshake.
Must be ≥ 65535 (the RFC default); values below that are silently ignored.
Default: 65535 (RFC 9113 §6.9.2 minimum). 4194304 (4 MiB) is a
common tuned value to allow concurrent streams to share the connection
budget without head-of-line stalls — see docs/reference/env-vars.md.
BB_H2_MAX_CONCURRENT_STREAMS
Maximum number of HTTP/2 streams the server accepts at the same time per
connection, advertised to peers in the initial SETTINGS frame
(RFC 9113 §6.5.2 — SETTINGS_MAX_CONCURRENT_STREAMS, identifier 0x0003).
Incoming streams that would exceed this limit receive RST_STREAM
REFUSED_STREAM and are not dispatched to the application.
Default: 100.
BB_H2_ACTIVE_STREAMS_1W
Per-connection asyncio.Semaphore cap on running stream handlers
when workers == 1. Counterpart of BB_H2_ACTIVE_STREAMS for
the single-worker case (where one event loop sees all connections).
0 disables the cap. Default: 20.
BB_H2_ACTIVE_STREAMS
Per-connection asyncio.Semaphore cap on running stream handlers
when workers > 1. Newly-spawned stream tasks queue for the
semaphore instead of running immediately, which prevents one high-mux
connection from monopolising the event loop and starving other
connections on the same worker. 0 disables the cap (no upper
bound beyond BB_H2_MAX_CONCURRENT_STREAMS). Default: 20.
BB_H2_ENABLE_WEBSOCKET
Advertise SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441 §3) so
peers may bootstrap WebSocket over HTTP/2 via Extended CONNECT.
Off by default — this path has fewer conformance tests than the
HTTP/1.1 upgrade path. Default: false.
BB_H2_WS_MAX_STREAMS_PER_CONNECTION
Maximum concurrent WebSocket (RFC 8441 Extended CONNECT) streams
per HTTP/2 connection. 0 disables the per-connection cap (no
upper bound beyond BB_H2_MAX_CONCURRENT_STREAMS). Only
meaningful when BB_H2_ENABLE_WEBSOCKET=1 — without that, no
WS-over-H2 streams are accepted at all. Defends against
stream-exhaustion DoS: without a per-connection cap, an attacker
can hold BB_H2_MAX_CONCURRENT_STREAMS idle WS streams open
per connection, multiplied by BB_MAX_CONNECTIONS.
Default: 5.
BB_WS_PERMESSAGE_DEFLATE
Negotiate permessage-deflate (RFC 7692) on incoming WebSocket
handshakes when the peer offers it. Matches modern browsers and
major WebSocket libraries. Default: true.
BB_WS_MAX_FRAME_PAYLOAD
Hard cap on the declared payload length (bytes) of a single
inbound WebSocket frame. RFC 6455 §5.2 allows up to 263 - 1; an
adversary post-handshake could advertise that to OOM the server
before any body bytes arrive. This cap is enforced on the
declared length in the frame header (before reading bytes off the
wire) and triggers CLOSE with status code 1009 (MESSAGE_TOO_BIG)
when exceeded. Default: 67108864 (64 MiB) — comfortably above
the largest frame the Autobahn|Testsuite sends (16 MiB, case 9.1.6)
while still bounding per-connection memory use. Lower for stricter
exposure (e.g. 1048576 for 1 MiB matching the
python-websockets default). This bounds the frame as it
arrives on the wire; what the application is handed after
reassembly and inflation is bounded by BB_WS_MAX_MESSAGE_SIZE.
BB_WS_MAX_MESSAGE_SIZE
Maximum size (bytes) of a WebSocket message as the application
receives it — after fragment reassembly and after
permessage-deflate inflation. This is the bound
BB_WS_MAX_FRAME_PAYLOAD cannot express: that one caps a single
compressed frame on the wire, and deflate ratios in this tree
measure 1028.8:1, so a frame at that cap inflates to ~64 GiB with
nothing between the peer and the allocator. Fragmentation is the
same defect without the compression: N frames each under the frame
cap accumulate with no total.
Exceeding it closes with 1009 (MESSAGE_TOO_BIG, RFC 6455 §7.4.1) and
logs a ws_max_message_size cap hit. 0 disables the cap.
Default: 16777216 (16 MiB) — the largest message the
Autobahn|Testsuite sends (9.1.6 text / 9.2.6 binary), so the suite
stays green on shipped defaults. An application that does not serve
huge messages should lower this: at the measured ratio a peer still
buys 16 MiB of server memory for ~16 KiB of upstream bandwidth.
BB_FRAME_RATE_LIMIT
Maximum number of each metered control frame a peer may send per
BB_FRAME_RATE_WINDOW, per type, per connection. Several
attack shapes share one form: a frame that is cheap to send and
obliges the server to a small piece of work per frame, so no byte
budget can see them and only a count can. Metered:
HTTP/2 RST_STREAM (CVE-2023-44487 Rapid Reset — inbound and
server-emitted), PING (CVE-2019-9512), SETTINGS
(CVE-2019-9515), zero-length CONTINUATION/DATA
(CVE-2019-9518 — invisible to BB_HEADER_MAX_TOTAL, which counts
bytes), and WebSocket control frames.
Each type gets its own budget, so a peer may legitimately spend its
allowance of PINGs and of SETTINGS without the two competing.
Exceeding it closes the connection (GOAWAY(ENHANCE_YOUR_CALM) on
HTTP/2, close 1008 on WebSocket) and logs a frame_rate cap
hit naming the frame type. 0 disables all frame-rate metering.
Default: 20 — generous for legitimate peers (browser navigation
plus prefetch cancellation rarely exceeds ~10 RST/s) and limiting for
the attack shapes, which run to thousands per second.
BB_FRAME_RATE_WINDOW
Width in seconds of the rolling window BB_FRAME_RATE_LIMIT
counts within. Default: 1.0.
BB_H2_IDLE_TIMEOUT
Seconds of complete silence on an HTTP/2 connection before the
server probes the peer with a PING. HTTP/2 connections are meant
to be long-lived and idle — a browser holds one across a page's
lifetime and a gRPC channel idles between calls — so reaping on
idleness alone would break both. Probing distinguishes idle from
gone: a peer that answers is never closed, and one that does not
answer within BB_H2_PING_TIMEOUT gets GOAWAY(NO_ERROR) and a
close. Any inbound frame counts as an answer. 0 disables the
probe entirely, leaving a silent connection bounded only by
BB_MAX_CONNECTIONS. Default: 300.0 (5 minutes).
BB_H2_PING_TIMEOUT
Seconds to wait for any frame after a liveness PING before
concluding the peer is gone and closing with GOAWAY(NO_ERROR).
Only meaningful when BB_H2_IDLE_TIMEOUT is non-zero.
Default: 30.0.
BB_WS_IDLE_TIMEOUT
Seconds of complete silence on a WebSocket connection before the
server probes the peer with a PING (RFC 6455 §5.5.2). Same
purpose, same triad column and same default as
BB_H2_IDLE_TIMEOUT — an idle WebSocket is normal, since a
subscription channel pushes nothing until something happens, so
reaping on idleness alone would break the legitimate case. Probing
distinguishes idle from gone: a peer that answers is never
closed, and one that does not answer within BB_WS_PONG_TIMEOUT
is closed with 1001 (Going Away). Any inbound frame counts as
an answer, not only a PONG — a peer that is talking to us is
demonstrably alive. This is the time column for a WebSocket
connection; the unit is BB_WS_MAX_FRAME_PAYLOAD and the
total is BB_WS_MAX_MESSAGE_SIZE for the message and
BB_MAX_CONNECTIONS for the connection. 0 disables the
probe, leaving a silent connection bounded only by
BB_MAX_CONNECTIONS. Default: 300.0 (5 minutes).
BB_WS_PONG_TIMEOUT
Seconds to wait for any inbound frame after a liveness PING before
concluding the peer is gone and closing with 1001. Only
meaningful when BB_WS_IDLE_TIMEOUT is non-zero — as
BB_H2_PING_TIMEOUT, and for the same reason.
Default: 30.0.
BB_MQTT_MAX_PACKET_SIZE
Maximum size (bytes) of a single inbound MQTT control packet,
advertised to clients as the Maximum Packet Size property in
CONNACK (§3.2.2.3.6) so a conforming client never sends one. The
check runs on the declared Remaining Length as soon as the fixed
header is readable, so an over-size packet is refused without
buffering its payload — MQTT 5 permits a peer to declare
268,435,455 bytes (256 MiB) and dribble them. Over the cap the
broker answers DISCONNECT with reason code 0x95 (Packet Too
Large) and closes. 0 disables the cap.
Default: 1048576 (1 MiB) — MQTT payloads are overwhelmingly small,
so a limit that admits a megabyte still admits every realistic message
while refusing the spec ceiling.
BB_MQTT_RECEIVE_MAXIMUM
The broker's own Receive Maximum (§3.2.2.3.3), advertised in
CONNACK: how many QoS>0 PUBLISH packets a client may have in flight
towards the broker before it must wait for acknowledgements.
This is a promise a conforming client keeps, not a gate the broker
closes — nothing counts a non-conforming client's in-flight
publishes against it. What bounds that direction is the 16-bit
packet-identifier space and BB_MQTT_MAX_PACKET_SIZE. The
client's Receive Maximum, in the outbound direction, is
enforced — see BB_MQTT_MAX_QUEUED_MESSAGES. Default: 64.
BB_MQTT_MAX_QUEUED_MESSAGES
Maximum QoS>0 messages held per session while the client's own
Receive Maximum window is full. MQTT 5 §4.9 forbids sending
more than that many unacknowledged PUBLISH packets, so a client that
subscribes and never acknowledges would otherwise make the broker
hold every matching message for the life of the session. Beyond
this bound the newest message is refused rather than an older one
silently discarded, and a cap hit is logged. 0 disables the
bound (unbounded backlog — not recommended on an exposed broker).
Default: 1000.
BB_MQTT_BROKER_INBOX_MAXSIZE
Waiting messages in the worker's MQTT broker inbox. At capacity, readers
await admission instead of decoding further packets. Positive integer,
independent of the per-session QoS backlog.
Default: 1024.
BB_MQTT_BROKER_INBOX_MAX_BYTES
Wire-size charge for waiting broker messages; positive integer. A packet
exceeding this budget cannot wait for admission and its connection is
refused. Not a Python heap limit.
Default: 16777216 (16 MiB).
BB_MQTT_CONNECTION_INBOX_MAXSIZE
Waiting packets in each MQTT connection writer inbox, including QoS 0
and control replies; positive integer. After yielding to the writer, a
full inbox ends only that connection, with a cap log.
Default: 1024.
BB_MQTT_CONNECTION_INBOX_MAX_BYTES
Encoded bytes waiting in each MQTT writer inbox; positive integer. The
active write is outside the queue budget; its duration is bounded by
BB_WRITE_TIMEOUT when enabled.
Default: 16777216 (16 MiB).
BB_MQTT_MAX_RETAINED
Maximum number of distinct topics holding a retained message
(§3.3.1.3). A retained message is permanent by design, so without a
bound one PUBLISH per topic grows broker memory forever. At the cap
a retained publish to a new topic is refused and logged;
updating or deleting an already-retained topic always works, so a
client can never be locked out of correcting its own state. The
message is still delivered to current subscribers — only the storage
is declined.
How the publisher learns depends on the QoS it chose, because that is
what decides whether the protocol has a channel for the answer:
QoS 1 and 2 receive 0x97 (Quota Exceeded) in the PUBACK or
PUBREC; QoS 0 is not told at all — it has no acknowledgement
(§3.3.4), and closing the connection over a storage quota would be
disproportionate and would also destroy a live delivery that
succeeded. A publisher that needs to know its retained state was
stored must use QoS ≥ 1. The operator sees every refusal in the
blackbull.caps log regardless. 0 disables the cap.
Default: 10000.
BB_MQTT_MAX_SUBSCRIPTIONS
Maximum Topic Filters one session may hold — the unit bound on
session state, whose total is BB_MQTT_MAX_SESSIONS and whose time
bound is the Session Expiry Interval the client declares. Without
it a single connected client grows broker memory without limit, and
with it the per-PUBLISH routing walk, since routing tests every
filter of every connected session. At the cap a new filter is
refused with 0x97 (Quota Exceeded) in the SUBACK and logged;
re-subscribing to a filter the session already holds always works
(§3.8.4 makes that a replacement, so it occupies no new slot).
0 disables the cap. Default: 1000.
BB_MQTT_MAX_SESSIONS
Maximum sessions the broker retains — the total bound on session
state, whose unit is BB_MQTT_MAX_SUBSCRIPTIONS plus
BB_MQTT_MAX_QUEUED_MESSAGES and whose time bound is the Session
Expiry Interval. A session outlives its connection by design, and
§3.1.2.11.2 defines 0xFFFFFFFF as never expires, so a peer
cycling Client Identifiers can pin one entry per identifier while
breaking no rule. At the cap a CONNECT for an unknown Client
Identifier is refused with 0x97 (Quota Exceeded) in the CONNACK
and the connection closed; a client resuming a session already in
the table is admitted, since refusing it frees nothing. Expired
sessions are swept before the cap is applied, so it binds live state
only. 0 disables the cap. Default: 10000.
BB_COMPRESSION_MIN_SIZE
Minimum response body size in bytes below which
:class:~blackbull.middleware.compression.Compression skips
compression entirely. Raising this threshold under load reduces CPU
pressure at the cost of slightly larger small responses.
Default: 100.
BB_COMPRESSION_EXECUTOR_THRESHOLD
Body size in bytes above which compression is offloaded to a thread-pool
executor so the event loop can continue processing other requests during
the (CPU-heavy) compress call. 0 always compresses on the event loop
(disables offloading). Default: 65536 (64 KiB).
BB_COMPRESSION_MAX_INFLIGHT
Maximum number of compression offloads allowed to be running
concurrently in the asyncio default thread pool. When at or above
this cap, additional eligible responses are served uncompressed
rather than queued — bounded fall-back rather than unbounded queue
growth. Tied to executor size: setting this above Python's default
ThreadPoolExecutor max_workers provides no benefit. That
default is min(32, os.cpu_count() + 4) on Python ≤ 3.12 and
min(128, os.cpu_count() * 5) on Python ≥ 3.13.
0 removes the cap, leaving an unbounded executor queue that
saturates under burst load.
Default: max((os.cpu_count() or 1) * 2, 4) — the floor keeps a
one- or two-CPU host overlapping a few offloads instead of
serialising them.
BB_BROTLI_QUALITY
Brotli quality level (0–11) for dynamic-response compression. The
brotli library's own default is 11 — designed for build-time/static
pre-compression and far too expensive on the request path. 4 matches
Google's and Cloudflare's dynamic-content recommendation; 5 matches
Apache mod_brotli's default; 6 matches nginx ngx_brotli's default; 11
is appropriate only for offline pre-compression of static siblings.
Default: 4.
BB_FRAME_YIELD_EVERY
Number of stream tasks spawned per connection before the frame loop
inserts await asyncio.sleep(0) to let the event loop dispatch the
queued tasks. Under burst traffic (e.g. 500 VUs all sending at once)
the frame loop can process many HEADERS frames without yielding, which
stalls all waiting tasks and inflates p99 latency. Yielding every N
spawns caps the maximum synchronous run to N × ~50 µs regardless of
burst size. 0 disables cooperative yielding.
Default: 8.
BB_UVLOOP
Install the uvloop event loop policy before each
asyncio.run() when the optional [speed] extra is installed.
Falls back to the standard asyncio loop with a warning if uvloop is
not importable. Default: false.
BB_FORCE_ASGI_SCOPE
Dual-path conformance lane. When enabled, every request round-trips the
native :class:~blackbull.connection.Connection through as_scope() +
from_scope() before dispatch, so the ASGI compat conversion is
exercised on the self-hosted path and cannot silently bitrot. Enabled in
CI; off in normal operation, where the native path skips the round-trip.
Default: false.
BB_DEADLINE_TICK_MS
Polling interval (milliseconds) for the per-process deadline scanner
that enforces connection timeouts (BB_HEADER_TIMEOUT,
BB_BODY_TIMEOUT, BB_WRITE_TIMEOUT, BB_KEEP_ALIVE_TIMEOUT).
Smaller = tighter timeout granularity at a small CPU cost; larger =
more slack but cheaper. Default: 300.
BB_CPU_PINNING
Per-worker CPU pinning, applied after fork in each worker process.
auto (default) gives worker i the i-th CPU of the mask the
process already carries, so taskset/numactl/cpuset placement is
honoured rather than overridden; off pins nothing; an explicit
taskset-style list (2,4,6-9 — 0 is CPU 0, not the off
switch) confines workers to those CPUs, intersected with the mask we
were granted. Only the event loop is pinned — the thread pool that
serves run_in_executor compression and asyncio.to_thread file
reads keeps the full mask. Multi-worker and Linux only; a single-worker
server is never pinned. Default: auto.
The BB_CLIENT_* block below bounds the async client, not the server.
The two are held apart deliberately: a server is addressed by anyone, while a
client picks its peer, so the same shape of limit gets a different default on
each side. Each entry states what it bounds; the trade-off behind each number
lives on the matching :class:Settings field.
BB_CLIENT_HEAD_MAX_TOTAL
Maximum total bytes in a response head the client will read (status line
+ all field lines + CRLFCRLF), bounded as it accumulates so an endless
header cannot grow the client's memory. Under HTTP/2 this bounds the
field lines in aggregate across every HEADERS frame on the stream.
0 disables.
Default: 65536.
BB_CLIENT_HEAD_MAX_LINE
Maximum bytes in a single status line or response field line. A policy
rule rather than a second memory guard — no line can be longer than the
already-bounded block containing it. 0 disables.
Default: 8192.
BB_CLIENT_HEAD_TIMEOUT
Seconds the client waits for a complete response head — the time column
for the read BB_CLIENT_HEAD_MAX_TOTAL bounds by size, since a peer
that sends half a head and stops passes every byte budget forever.
0 disables.
Default: 30.0.
BB_CLIENT_WRITE_TIMEOUT
Maximum seconds the client waits for one send-progress operation: a
socket drain on HTTP/1.1 and WebSocket, one SETTINGS_MAX_FRAME_SIZE
unit of DATA plus its flow-control credit on HTTP/2. There is
intentionally no whole-upload total owner. 0 disables.
Default: 30.0.
BB_CLIENT_BODY_TIMEOUT
Seconds the client waits for a single response-body read — per read, not
per body, so a peer must keep making progress. Armed by the final
response head rather than the request, and re-armed only by octets that
are body payload. 0 disables.
Default: 30.0.
BB_CLIENT_BODY_MAX_TOTAL
Maximum total response-body octets the client will buffer for one
response. Bounds receive() only — stream() exists so a large
response need not fit in memory. What it counts is body octets; what it
costs is about 2× that at the join. Off by default, unlike the
server's BB_MAX_BODY_SIZE: that number bounds what strangers push
into a process, this one bounds what you asked for.
Default: 0 (disabled).
BB_CLIENT_MIN_BODY_RATE
Minimum sustained rate, in octets per second, at which the client
requires a response body to arrive — what BB_CLIENT_BODY_TIMEOUT
cannot express, since it returns on any arrival. The numerator is
payload only, so framing octets a peer may pad buy no credit, and the
wait before the first body octet is outside the window. Off by
default: after the first octet, an event stream and a drip are the
same observation. 0 disables.
Default: 0.0 (disabled).
BB_CLIENT_MIN_BODY_RATE_GRACE
Seconds of body-read waiting, after the first body octet, before
BB_CLIENT_MIN_BODY_RATE is enforced. The window rolls forward
whenever it is satisfied, so a burst buys the window it happened in
rather than the whole response.
Default: 5.0.
BB_CLIENT_MAX_INTERIM_RESPONSES
Maximum interim (1xx) responses the client reads and discards while
waiting for the final one. RFC 9110 §15.2 makes parsing past them a
MUST, which turns "read one response" into a loop, and a loop over
peer-supplied messages needs a count — this one, since the head budget
and head deadline are both per head. 101 is not counted: 1xx by
number, final by meaning. 0 disables the cap.
Default: 8.
BB_CLIENT_RAW_QUEUE_DEPTH
Frames the client holds for one raw HTTP/2 stream — the escape hatch
where the receive loop hands frames to a registrant instead of the
request/response machine. Full resets that stream alone with
ENHANCE_YOUR_CALM; the connection survives. Denominated in frames,
because flow control charges only a DATA payload and a zero-length DATA
frame would buy depth for free. 0 disables (unbounded).
Default: 1024 — a peer may legally burst its whole 65535-byte window
as small frames, and a raw stream cannot be dropped without corrupting
it, so this must not fire on legal traffic.
BB_CLIENT_H2_MAX_FRAME_SIZE
Maximum octets in one inbound HTTP/2 frame payload the client will read,
judged from the frame header so a peer-declared number never sizes an
allocation. Breach is a connection error of type FRAME_SIZE_ERROR:
refusing before the read leaves the payload in the socket. 0
disables.
Default: 16384 — RFC 9113 §6.5.2's initial SETTINGS_MAX_FRAME_SIZE,
the one value that neither refuses a conforming peer nor accepts what was
never advertised.
BB_CLIENT_H2_MAX_HEADER_LIST_SIZE
Maximum octets in one decoded field section the client accepts. One
number, two effects: advertised as SETTINGS_MAX_HEADER_LIST_SIZE and
installed as the HPACK decoder's max_header_list_size, which must
agree because §6.5.2 makes the announcement advisory and the decoder the
defence. The only bound counted in decoded octets, since compression
decouples the two sizes. Breach is a connection error of type
COMPRESSION_ERROR. 0 disables.
Default: 65536 — what hpack enforces unasked, so the default changes
what the peer is told, not what is accepted.
BB_CLIENT_H2_ENABLE_PUSH
Whether the client permits the peer to push (RFC 9113 §6.5.2). A
conformance switch, not a bound. Enabled advertises nothing (1 is the
parameter's initial value) and a PUSH_PROMISE is decoded and dropped;
disabled advertises SETTINGS_ENABLE_PUSH=0 and refuses a later
PUSH_PROMISE with a connection error of type PROTOCOL_ERROR, §6.5.2
making that refusal a MUST for whoever sends the 0.
Default: true.
BB_CLIENT_WS_MAX_FRAME_PAYLOAD
Client WebSocket inbound frame payload cap, in bytes per frame.
BB_CLIENT_WS_MAX_MESSAGE_SIZE owns the aggregate message total; the
client WebSocket recipient has no environment-owned time bound.
Default: 67108864 (64 MiB).
BB_CLIENT_WS_MAX_MESSAGE_SIZE
Client WebSocket inbound message cap, in bytes per message — the total
for which BB_CLIENT_WS_MAX_FRAME_PAYLOAD is the unit.
Default: 16777216 (16 MiB).
Settings
dataclass
¶
Immutable snapshot of all runtime settings.
Construct via :func:get_settings rather than directly so that
environment variables are read at the right time.
apply_event_loop_policy(cfg=None)
¶
Install uvloop as the asyncio event loop policy if BB_UVLOOP=1.
Call this once before each asyncio.run() entry point. Safe to call
multiple times (subsequent calls are no-ops when the policy is already set).
If uvloop is not installed a warning is logged and the standard policy is
kept; the server still starts.
get_settings()
cached
¶
Read environment variables and return an immutable :class:Settings.
Cached: first call parses env vars and builds the dataclass; subsequent
calls return the same instance. Settings are server-process-wide
configuration, not per-request data — there's no reason to re-parse
os.environ on every request. Profile showed _int_env and
_int_env_nonneg consuming ~5–6% of CPU in the HTTP/1.1 hot path
before this cache.
Tests that mutate environment between cases must call
:func:reset_settings_cache in their teardown.
reset_settings_cache()
¶
Clear the cached :class:Settings.
Call this in test teardown if the test mutated env vars that
:func:get_settings reads. Without this, the cached settings reflect
whatever environment was visible the first time get_settings() ran
in the process.
resolve_max_connections(raw)
¶
Resolve BB_MAX_CONNECTIONS — auto, 0, or a number.
auto (the default) derives the cap from this process's own
RLIMIT_NOFILE. A cap above the file-descriptor budget is
decorative: accept() fails with EMFILE before the cap is
consulted, so the peer gets a dropped connection instead of the
503 + Retry-After the mechanism exists to send. Derived, the cap
can only refuse connections the OS was going to refuse anyway — which
is what makes a finite default safe to ship — and it tracks the
operator's own intent, since raising the fd limit is how an operator
states how large this process may become.
An explicit number is honoured as given, not clamped to the fd
budget: an operator who names a number means it, and silently running
a different one would make the live configuration differ from the
configured one with nothing to show for it. 0 disables the cap.
Note this bounds descriptor exhaustion, not event-loop health. A ceiling reflecting what one asyncio loop serves well is a policy number that depends on the workload — set it explicitly; 1024 is a typical single-loop value.