HTTP/2¶
BlackBull speaks HTTP/2 natively. ALPN negotiates h2 on the
same TLS listener that serves HTTP/1.1; cleartext h2c is detected
on first preface bytes (no separate port required, no upgrade
dance). This page covers what the framework does with HTTP/2's
extra knobs and what your app code can do with them.
Activating HTTP/2¶
app.run(port=8443, certfile='cert.pem', keyfile='key.pem')
ALPN advertises both h2 and http/1.1. Whichever the client
picks is what gets used. Browsers and modern HTTP clients
(curl with --http2, httpx with http2=True, h2load) negotiate
h2; older clients negotiate http/1.1 and continue working
unchanged.
For cleartext h2c (no TLS, useful inside a private network behind a load balancer that terminates TLS), bind a plain port and let the protocol detector sniff the connection preface:
app.run(port=8080) # serves both HTTP/1.1 and h2c on the same socket
Priority hints¶
HTTP/2 lets a client tell the server which of its outstanding
requests is more important. BlackBull surfaces the hint on the
the Connection so your app can act on it.
What lands on the Connection¶
Every HTTP/2 request advertises the priority hint via the
http.response.priority ASGI extension (matches gunicorn's beta
HTTP/2 key shape; the contents are RFC 9218 urgency/incremental,
not the deprecated RFC 7540 weight/tree):
conn.extensions['http.response.priority']
# → {'urgency': int, 'incremental': bool}
| Key | Type | Default | Meaning |
|---|---|---|---|
urgency |
int 0–7 |
3 |
0 = most urgent, 7 = least urgent |
incremental |
bool |
False |
Client accepts interleaved partial responses |
BlackBull resolves the value in this order (first wins):
PRIORITY_UPDATEframe (RFC 9218, type0x10) received from the client — either before or after the HEADERS frame.priorityHTTP header in the request (e.g.priority: u=1, i).- RFC 9218 §4.1 defaults:
urgency=3,incremental=False.
For HTTP/1.1 requests the extension key is absent. Always use
.get() with a default so your handler works across both protocols.
Using it in a handler¶
_DEFAULT_PRIORITY = {'urgency': 3, 'incremental': False}
@app.route(path='/search')
async def search(conn, receive, send):
hint = conn.extensions.get('http.response.priority', _DEFAULT_PRIORITY)
if hint['urgency'] <= 2:
# High-urgency: return cached / pre-computed result immediately
result = get_cached_result()
else:
# Normal / background: run the full search
result = await run_full_search()
await send(JSONResponse(result))
Across every route, via an interceptor¶
@app.intercept('before_handler')
async def handle_priority(event):
conn = event.detail['conn']
hint = conn.extensions.get('http.response.priority',
{'urgency': 3, 'incremental': False})
if hint['urgency'] <= 1:
logger.info('HIGH-PRIORITY u=%d: %s %s',
hint['urgency'], event.detail['method'], event.detail['path'])
Migrating from scope['http2_priority'] (removed in v0.75.0)¶
Earlier BlackBull releases exposed priority as a top-level scope
key, scope['http2_priority']. v0.31 moved it under
conn.extensions to match ASGI conventions and align the key
name with gunicorn's beta HTTP/2 surface, kept the old key
populated as a deprecation alias, and removed it in v0.75.0.
The extension is not merely the newer spelling — it is the only
one that was ever correct. extensions is shared by reference
with the Connection, so a PRIORITY_UPDATE arriving after
dispatch reaches the application through it. The top-level key
was a dispatch-time copy and silently went stale.
To migrate, replace:
hint = scope.get('http2_priority', DEFAULT) # removed in v0.75.0
with:
hint = conn.extensions.get('http.response.priority', DEFAULT) # v0.31+
The dict shape ({'urgency': int, 'incremental': bool}) is
unchanged.
HTTP/2 stream info (gRPC foundation)¶
Alongside the priority extension, v0.31 adds
conn.extensions['http.response.http2_stream'] — a snapshot of
the HTTP/2 stream identity and send-flow-control credit at request
entry:
conn.extensions['http.response.http2_stream']
# → {'stream_id': int, 'send_window_remaining': int,
# 'connection_send_window_remaining': int}
| Key | Meaning |
|---|---|
stream_id |
The HTTP/2 stream ID this request arrived on (odd for client-initiated, even for server pushes) |
send_window_remaining |
Bytes the peer will currently accept on this stream before WINDOW_UPDATE |
connection_send_window_remaining |
Same, at the connection level |
The window values are snapshots taken when the Connection is built; they
move as the response body streams. Applications that need
live readings (e.g. gRPC server-streaming back-pressure) can
re-read the dict, though they will see the snapshot value unless
they keep a reference and the populate site re-fetches it — which
v0.31 does not do. Live properties are a future
consideration.
Peer-side receive-window is intentionally absent: BlackBull sends
WINDOW_UPDATE per consumed DATA frame, so there is no scalar to
snapshot.
See examples/PriorityExample/
for a dedicated server + client pair that demonstrates both
sources (PRIORITY_UPDATE frame and priority header).
Testing priority with curl¶
# Default priority (urgency=3)
curl --http2 -k https://localhost:8443/priority-echo
# High urgency
curl --http2 -k -H 'priority: u=1' https://localhost:8443/priority-echo
# Background prefetch, incremental
curl --http2 -k -H 'priority: u=6, i' https://localhost:8443/priority-echo
curl converts the priority header to a PRIORITY_UPDATE frame
when talking HTTP/2 over TLS. httpx (Python) sends it as a plain
header; BlackBull parses both forms.
What BlackBull does not do with priority¶
BlackBull's stance is receive but do not schedule. Priority
signals are accepted, logged at DEBUG, and made available on
the scope — but the framework does not reorder responses based on
them. Your application decides what to do with the hint.
This is valid per RFC 9218 §2: "A server that does not implement prioritization MUST ignore this frame." The framework reports the hint to you; you choose whether and how to honour it.
Server push¶
Server push lets the server proactively send a resource to the client before the client asks for it. A typical use case is pushing a CSS file alongside the HTML page that references it, saving one round-trip.
Triggering a push¶
The app signals a push by calling send with an
http.response.push event before the final http.response.body:
@app.route(path='/')
async def index(conn, receive, send):
# Push a stylesheet before sending the HTML.
await send({
'type': 'http.response.push',
'path': '/static/style.css',
'headers': [],
})
await send({'type': 'http.response.start', 'status': 200,
'headers': [(b'content-type', b'text/html')]})
await send({'type': 'http.response.body',
'body': b'<html>...</html>'})
path must be a plain string (percent-encoding decoded).
Pseudo-headers (:method, :scheme, :authority) are filled in
automatically — do not include them in headers.
What the framework does¶
- Allocates the next even stream ID for the pushed resource (RFC 7540 §5.1.1 — server-initiated streams are always even: 2, 4, 6, …).
- Sends a
PUSH_PROMISEframe on the parent stream. The frame contains the synthetic request headers (GET /static/style.css) the client can use to match its cache. - Creates a synthetic
Connection(type='http',method='GET',path='/static/style.css') and dispatches it to your app as a new task on the promised stream. Your app handles the pushed request exactly like a normal GET — same route, same middleware, same response cycle.
Checking for support¶
The server advertises push support in conn.extensions when the peer permits
it at request-dispatch time:
if 'http.response.push' in conn.extensions:
await send({'type': 'http.response.push',
'path': '/logo.png', 'headers': []})
For HTTP/1.1 requests conn.extensions does not contain
'http.response.push', so the guard above keeps the same handler
working on both protocols. If the peer changes SETTINGS_ENABLE_PUSH after
the scope was built, the send path checks the current connection permission as
well: a late push event is logged and dropped without sending a
PUSH_PROMISE or creating a pushed request.
Caveats¶
- Clients can disable server push by sending
SETTINGS_ENABLE_PUSH=0. BlackBull tracks that connection setting (whose RFC 9113 §6.5.2 initial value is1) and omits the push extension from subsequently dispatched scopes. A push event from a scope built before the setting changed is logged and dropped at send time, so it cannot send aPUSH_PROMISEafter permission was withdrawn. SendingSETTINGS_ENABLE_PUSH=1re-enables push for later events and scopes. - Pushed resources should be cacheable. Pushing non-cacheable content wastes bandwidth and may confuse browsers.
Flow control¶
HTTP/2 has two independent flow-control windows — connection-level (shared across all streams on one connection) and stream-level (per-stream budget). BlackBull handles both internally; no app code needs to interact with flow control directly.
Outgoing DATA consumes both windows before the transport can suspend a write
or drain. Other streams may send concurrently using the remaining credit, but
cannot reuse bytes already handed to the writer. Cancellation, stream reset,
or a failed drain does not return that credit: delivery may already have
occurred. Peer WINDOW_UPDATE grants more credit; initial-window SETTINGS also
adjust stream credit. A peer reducing its
initial stream window can legitimately leave a negative balance; body sending
then waits for positive credit. Control frames and an empty DATA END_STREAM
do not need available DATA credit.
These rules apply to response bodies, deferred gRPC bodies and trailers, client
uploads, and WebSocket-over-H2 traffic, including the client's close frame.
The client's close send uses BB_CLIENT_WRITE_TIMEOUT; its subsequent wait for
the peer's close uses drain_timeout. Cancellation during either wait releases
the session's reader and raw-stream registration. The explicit send_raw_frame
fault-injection escape hatch is not a normal flow-controlled send API.
Configuration knobs that affect flow-control behaviour live in
the BB_H2_* family of environment variables — see
Configuration for the full list.
WebSocket over HTTP/2¶
WebSocket can be carried over HTTP/2 via RFC 8441 (Extended
CONNECT). Off by default; opt in via
BB_H2_ENABLE_WEBSOCKET=1. See
WebSockets — Transport
for the details.
Behind a reverse proxy¶
A proxy that terminates HTTP/2 from clients but speaks HTTP/1.1 to BlackBull
gives you HTTP/2 on the front leg only — the native HTTP/2 support described
on this page goes unused. To keep it end-to-end, the proxy needs an HTTP/2
backend: HAProxy (server … proto h2) and Envoy
(http2_protocol_options: {}) have both shipped that for years, while
nginx's proxy_http_version 2 arrived only in 1.29.4.
An HTTP/1.1 back leg remains a fine default for ordinary traffic. See Behind a reverse proxy for the trade-off and complete configurations.
Next¶
- Behind a reverse proxy — HTTP/2 backend configuration for HAProxy, Envoy, and nginx.
- WebSockets — the WebSocket transport, including HTTP/2 carriage.
- Configuration —
BB_H2_*flow-control, concurrency, and window-size environment variables. - Internals — the HTTP/2 actor model, stream state machine, and frame parser, for readers who want to know what the framework is doing on the wire.