Module: Wurk::API::Throttle
- Defined in:
- lib/wurk/api/throttle.rb
Overview
Per-token request ceiling for the HTTP API.
A Limiter::Window per credential, not a second limiter: Wurk
already owns a sliding window whose trim-count-add step is one atomic Lua
call timed off the Redis clock, so a fleet of web processes shares one
verdict and a producer's own clock cannot argue with it. Reusing it also
means an API ceiling is a limiter like any other — it registers into
lmtr-list, and GET /v1/limiters and the dashboard's Limiters page show
it beside the ones the jobs use.
The window is named for the token's fingerprint (a truncated, domain-separated digest — Auth#fingerprint), never the token: the name is a Redis key and shows up in the dashboard.
Off unless config.api_rate_limit is set, and then it is exactly one
extra round trip per request. Nothing here rescues a Redis failure: a
window that cannot be read cannot limit, and an API that quietly drops
its ceiling the moment Redis is in trouble sheds it precisely when the
load matters. The error surfaces as App's 500, which a client retries.
Constant Summary collapse
- NAME_PREFIX =
'api-token:'- NO_WAIT =
No waiting.
within_limitsleeps until a slot frees, which is right for a worker thread and wrong for a socket a client is holding open — an over-limit request has an answer already (429 + Retry-After), and it is cheaper for both ends than a held connection. 0- MIN_RETRY_AFTER =
Retry-After is a whole number of seconds (RFC 9110 §10.2.3) and must never be 0: a client that reads "wait 0" retries immediately, which is what got it throttled.
1- LOCK =
Mutex.new
Class Method Summary collapse
- .cache ⇒ Object
-
.for_principal(principal, limit, interval) ⇒ Object
Limiters are stateless Ruby objects — every operation is a Lua call — so one per (credential, ceiling) is safe to keep and share across threads and across a fork.
- .interval_seconds(interval) ⇒ Object
- .over_limit(request, limiter, limit, interval) ⇒ Object
-
.refuse(request) ⇒ Array?
A 429 problem triple, or nil when the request may proceed (including when no limit is configured).
-
.retry_after(limiter, interval) ⇒ Object
reset_atis when the window's oldest entry slides out — the first moment a slot exists — read fresh rather than guessed from the interval, which would tell a client that trickled up to the ceiling to wait a full window for a slot that frees in a second.
Class Method Details
.cache ⇒ Object
75 76 77 |
# File 'lib/wurk/api/throttle.rb', line 75 def cache @cache ||= {} end |
.for_principal(principal, limit, interval) ⇒ Object
Limiters are stateless Ruby objects — every operation is a Lua call — so one per (credential, ceiling) is safe to keep and share across threads and across a fork. Kept because constructing one writes its metadata hash, and paying that round trip per request would double the cost of the feature. Keyed by the ceiling too, so a host that changes it gets a limiter that agrees with the config rather than the one built first.
64 65 66 67 68 69 70 71 72 73 |
# File 'lib/wurk/api/throttle.rb', line 64 def for_principal(principal, limit, interval) key = [principal.id, limit, interval] found = LOCK.synchronize { cache[key] } return found if found # Built outside the lock: registration is a Redis round trip, and a # racing second build only rewrites the same metadata hash. built = ::Wurk::Limiter.window("#{NAME_PREFIX}#{principal.id}", limit, interval, wait_timeout: NO_WAIT) LOCK.synchronize { cache[key] ||= built } end |
.interval_seconds(interval) ⇒ Object
90 91 92 |
# File 'lib/wurk/api/throttle.rb', line 90 def interval_seconds(interval) ::Wurk::Limiter.interval_seconds(interval, allow_integer: true) end |
.over_limit(request, limiter, limit, interval) ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/wurk/api/throttle.rb', line 94 def over_limit(request, limiter, limit, interval) seconds = retry_after(limiter, interval) Problem.render( Problem::RATE_LIMITED, status: 429, detail: "This token is limited to #{limit} requests per #{interval}.", instance: request.path, headers: { 'retry-after' => seconds.to_s }, limit: limit, interval_seconds: interval_seconds(interval), retry_after: seconds ) end |
.refuse(request) ⇒ Array?
Returns a 429 problem triple, or nil when the request may proceed (including when no limit is configured).
47 48 49 50 51 52 53 54 55 56 |
# File 'lib/wurk/api/throttle.rb', line 47 def refuse(request) limit = request.config.api_rate_limit return nil unless limit interval = request.config.api_rate_limit_interval limiter = for_principal(request.principal, limit, interval) limiter.within_limit { nil } rescue ::Wurk::Limiter::OverLimit => e over_limit(request, e.limiter, limit, interval) end |
.retry_after(limiter, interval) ⇒ Object
reset_at is when the window's oldest entry slides out — the first
moment a slot exists — read fresh rather than guessed from the interval,
which would tell a client that trickled up to the ceiling to wait a full
window for a slot that frees in a second. One extra read, on the refused
path only.
84 85 86 87 88 |
# File 'lib/wurk/api/throttle.rb', line 84 def retry_after(limiter, interval) reset_at = limiter.status[:reset_at] seconds = reset_at ? (reset_at - ::Time.now.to_f).ceil : interval_seconds(interval) [seconds, MIN_RETRY_AFTER].max end |