YmerNode.Scripts.Throttle (Ymer Node v0.2.1)

Copy Markdown View Source

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 note

Any 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

attach(request, declared, deadline)

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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

list()

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.

record(name, status)

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.

reset(name)

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.

take(name, parameters, deadline)

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.