Skip to content

blackbull.server.rate_window

blackbull.server.rate_window

A rolling-window rate meter — the codebase's shared defence primitive.

Several attack shapes are the same shape: a frame that is cheap for a peer to send and obliges the server to do a small piece of work per frame. A PING costs an ACK write, a SETTINGS costs an ACK write, a zero-length CONTINUATION costs a parse and a loop turn, a WebSocket PING costs a PONG. None of them is large, so no byte budget sees them; each is unbounded in count, which is the axis that matters.

This module exists so that answer is written once. BlackBull already metered exactly one frame type — inbound RST_STREAM, from the Rapid Reset work (CVE-2023-44487) — with the window inlined in the frame loop. Four more sites needed the same logic, and four more copies of it would have been the first duplicated check in this server's defences.

Deliberately not a token bucket: a fixed window is what the Rapid Reset counter already was, its constants are calibrated against real traffic, and a burst-tolerant refill curve would change the meaning of limits that were chosen by observation. If a site ever needs smoothing, it gets a second primitive with its own name, not a quietly different hit().

ByteRateFloor

Minimum sustained byte rate over a rolling window.

:class:RateWindow counts events; this weighs octets against the time spent waiting for them, which is the other half of the same defence and a different question. The module docstring's rule applies: a site needing different arithmetic gets a second primitive with its own name rather than a quietly different hit().

Why a rate and not a deadline: a per-read timeout returns on any arrival, so it degrades from "deliver a slice in N seconds" to "send something every N seconds", which a one-byte drip always satisfies. A rate is what a drip cannot fake (Kestrel's MinRequestBodyDataRate).

The window is one grace period wide and rolls when it is satisfied, so a burst buys the window it happened in and not the whole message: a peer that ran ahead and then stalled is judged on the stall. Nothing is judged before a grace period of waiting has accumulated.

Both arguments to :meth:record are the caller's to define, and the difference between them is the whole design. waited should be every second the caller sat on the transport, including reads that delivered nothing countable — otherwise a peer stalls before the parts that are not counted and buys unbounded time. nbytes should be only the octets the caller actually wanted; framing a peer can pad at will is not payload. A rate of 0 disables the floor, which is how every cap in this tree spells "off".

observed property

Bytes per second in the window currently open (diagnostics only).

record(nbytes, waited)

Add one delivery. Returns True when the window came up short.

reset()

Forget the current window. For reuse, not for callers that dislike the answer.

RateWindow

Count events per fixed window; report when the budget is spent.

limit events are permitted per window seconds. A limit of 0 disables the meter entirely — :meth:hit then never reports an overrun, which is how every cap knob in this server spells "off".

One instance per thing being counted, per connection: separate meters for PING and SETTINGS mean a peer may legitimately send its budget of each, and a shared meter would have made the two compete for one allowance for no reason a peer could predict.

count property

Events counted in the window currently open (diagnostics only).

hit(now=None)

Count one event. Returns True when the budget is exceeded.

now accepts an injected clock so a test can drive window rollover deterministically instead of sleeping through it.

reset()

Forget the current window. For connection reuse, not for callers that dislike the answer.