One throttle on this node: the process that holds its bucket, its breaker and the requests waiting on it — and the two request steps that put a request naming it through that process.
What a throttle is, and how a script declares and names one, is written at
YmerNode.Script.throttle/0 and in YmerNode.Script.Context's Throttles
section; this module is how the node keeps that promise.
One process per name, started by the first request
A throttle is a process registered under its name in
YmerNode.Scripts.Throttles and started under
YmerNode.Scripts.ThrottleSupervisor by the first request that names it —
never at boot, because nothing needs a throttle before a script asks for one.
It starts holding that request's parameters, so a throttle the registry names
always has a bucket to report, even to a listing that reaches it before that
request is served. It lives across runs and across the scripts that name it,
and a node that restarts forgets it: tokens and a breaker's count are nothing
a restart owes anyone, which is the durability rule applied (YmerNode). It
is started :temporary, so a throttle that crashed is started fresh by the
next request rather than counting against its supervisor's restarts.
Its parameters arrive with every request, from the declaration of the script
making it. A script updated to other parameters is therefore obeyed at its
next request: the bucket is refilled at the old rate up to that moment and
held to the new burst (YmerNode.Scripts.Throttle.Bucket.configure/4), while
the breaker keeps its state and its count — an open one stays open, and is
listed open, even when the new declaration drops the breaker — because a
locked account stays locked whatever numbers a script now declares. Two
accepted scripts only ever hand one name the same parameters —
YmerNode.Scripts refuses the write that would disagree.
Waiting
A request takes a token when the bucket holds one and nobody is waiting;
otherwise it waits its turn, first come first served. It waits at most until
its run's deadline: a request the tokens and the waiters ahead say cannot be
served before then is refused at once, naming the wait it would need
(YmerNode.Scripts.Throttle.Bucket.wait/2), so a run never sits in a queue
until the runner kills it with an answer that names the run and not the
throttle. The estimate counts every waiter ahead as taking its token, so a
request it lets wait is served in time unless a lower rate arrives meanwhile,
and then the runner's kill at the deadline ends the wait.
Every waiter is monitored. The runner kills a run at its deadline as a matter of course, so a waiter dying in the queue is the ordinary case, and a dead waiter is dropped when its monitor fires and never handed a token.
The breaker
A throttle declared with a breaker counts consecutive 401 responses. At the
threshold it opens: every waiter is answered at once, and every request is
refused until the cooldown passes or an operator resets it with the CLI
(YmerNode.Scripts.CLI) — the operator's door and not a session's, because a
session able to reset it would reset it against the very failure it guards.
There is no half-open trial: the bucket already bounds how many requests
start at once, so a wrong secret costs at most the threshold's worth of failed
logins per cooldown.
stateDiagram-v2
[*] --> Closed : the first request naming it
Closed --> Closed : a 401 below the threshold, or any other status
Closed --> Open : the threshold's consecutive 401
Open --> Closed : the cooldown passes
Open --> Closed : an operator resets it
note right of Open
every waiter and every new request
is refused at once; a response
arriving now is ignored
end noteAny status but 401 zeroes the count, which is what tells a changed password from a busy hour; a request that got no response — a transport error — leaves the count as it was. The opening is logged as a warning naming the count and the cooldown, the closing as info naming how it closed.
The steps
attach/3 adds two steps to a Req request, and they sit on opposite sides of
Req's own. The request step is appended: Req re-runs every request step
for a retried attempt, so each attempt pays a token. The response step is
prepended: Req's retry is itself a response step, and a step behind it
sees only the last attempt's status, so a breaker fed there would miss the
401s of every attempt but the last. A refusal halts the request with a
YmerNode.Scripts.Throttle.Error before anything is sent, and a halted request
runs no error step, so no retry policy a script passes can retry a refusal.
Summary
Functions
Puts a Req request through the throttle its throttle: option names, among
the ones declared — name to parameters — and within deadline. A request
naming none passes both steps untouched, and one naming a throttle declared
does not hold is refused before it is sent. Why the two steps sit where they
do is § The steps above.
Returns a specification to start this module under a supervisor.
Every throttle this node has started, in name order: its tokens, burst and
rate, how many requests wait on it, and its breaker — nil for a bucket
alone, otherwise the threshold, the count, and resumes_in, the milliseconds
until an open breaker closes by itself (nil while it is closed). A breaker
a new declaration dropped while it was open stays listed until it closes,
with a nil threshold.
Hands the named throttle the status of a response to a request it gave a token to. Asynchronous, and nothing at all when no throttle of that name has started.
Closes the named throttle's breaker and clears its count — the operator's
verb. Answers :ok, or {:error, {:throttle_not_started, detail}} when no
throttle of that name has started on this node.
Takes one of the named throttle's tokens for a request, starting the throttle if nothing has named it since the node started.
Functions
Puts a Req request through the throttle its throttle: option names, among
the ones declared — name to parameters — and within deadline. A request
naming none passes both steps untouched, and one naming a throttle declared
does not hold is refused before it is sent. Why the two steps sit where they
do is § The steps above.
Returns a specification to start this module under a supervisor.
See Supervisor.
Every throttle this node has started, in name order: its tokens, burst and
rate, how many requests wait on it, and its breaker — nil for a bucket
alone, otherwise the threshold, the count, and resumes_in, the milliseconds
until an open breaker closes by itself (nil while it is closed). A breaker
a new declaration dropped while it was open stays listed until it closes,
with a nil threshold.
Hands the named throttle the status of a response to a request it gave a token to. Asynchronous, and nothing at all when no throttle of that name has started.
Closes the named throttle's breaker and clears its count — the operator's
verb. Answers :ok, or {:error, {:throttle_not_started, detail}} when no
throttle of that name has started on this node.
Takes one of the named throttle's tokens for a request, starting the throttle if nothing has named it since the node started.
parameters are the declaring script's, and the throttle adopts them before
it answers; deadline is the run's, as a System.monotonic_time(:millisecond)
instant. Answers :ok once a token is the caller's, or
{:error, %YmerNode.Scripts.Throttle.Error{}} — at once — when the breaker is
open or the wait would outlast the deadline.