AuroraMeter. Plug. EnsureEntitled
(Aurora Meter v1.0.0)
View Source
This check is advisory. It refuses a request the plan plainly does not allow, before your controller runs. It does not hold anything, and between its decision and your controller's work another request on the same node can take the last unit.
The only strict admission is AuroraMeter.with_quota/4 around the work
itself, and no reserving plug is shipped, deliberately. A reservation has
to be released when the work fails, with_quota/4 releases it by running the
work inside its own try/catch, and a plug cannot see what the controller
returned or whether it raised. A plug that reserved would hold quota it could
never give back for every failed action (invariant I03), and would admit N
concurrent requests whose controllers each reserved again on top (invariant
I04). Use both: the plug for the cheap early refusal and the clear 403, and
with_quota/4 where the money is.
It is not authorization. It answers "does this tenant's plan allow this
feature", never "is this request allowed to act for this tenant". The second
question is permanently yours, and the :tenant resolver is required with no
default so that it is asked explicitly. A resolver that returns nil gets a
401 and nothing is written: an unresolved tenant is never the default tenant.
pipeline :metered do
plug AuroraMeter.Plug.EnsureEntitled,
feature: :api_calls,
tenant: &MyAppWeb.Tenancy.current_org/1
endOptions
| Option | Type | Default | Meaning |
|---|---|---|---|
:feature | atom, required | none | the feature to check; a binary is refused at compile time |
:tenant | (Plug.Conn.t() -> tenant | nil) or {module, function}, required | none | your resolver; there is no default |
:mode | :check | :entitled? | :check | :check consults usage, so it sees hard limits; :entitled? consults only the plan shape and reads no counter |
:assign_quota | boolean | true | assign :aurora_meter_quota on a passing request |
:on_missing_tenant | :unauthorized | {module, function} | :unauthorized | 401, reason :missing_tenant |
:on_denied | :forbidden | {module, function} | :forbidden | 403, reason :not_entitled or :limit_exceeded |
:on_unavailable | :service_unavailable | {module, function} | :service_unavailable | 503, reason :unavailable |
init/1 validates its options, and Phoenix expands a router plug call at
compile time, so a bad option is a compile error in your application rather
than a surprise on the first request.
What a passing request gets
:aurora_meter_tenant, the term your resolver returned, and (unless
assign_quota: false) :aurora_meter_quota, exactly
AuroraMeter.quota(tenant, feature). The quota costs one more ETS read and
one period computation than the decision does; turn it off on a hot path
that does not render it.
What a refused request gets
The bare atoms send an empty body and halt, with the reason atom in
conn.private[:aurora_meter_denial] so a downstream error handler or your
endpoint can render whatever your API renders. The plug picks no content
type and writes no body of its own, because it does not know whether your
client wants JSON.
Pass {module, function} to render it yourself. It is called with
(conn, reason) and must return a halted conn; one that does not raises
a RuntimeError naming your callback, because an unhalted conn after a
denial is a request that was refused and then served anyway.
plug AuroraMeter.Plug.EnsureEntitled,
feature: :api_calls,
tenant: {MyAppWeb.Tenancy, :current_org},
on_denied: {MyAppWeb.Errors, :quota_json}Why 403 and not 429. :limit_exceeded is an entitlement outcome for the
current period, not a rate limit that clears in seconds, so a Retry-After
would be a lie. Both denial reasons default to 403 and the reason atom is
carried through to your callback, so mapping :limit_exceeded to 402 or 429
is one clause in one place.
Errors it does not hide
AuroraMeter.UndeclaredFeatureError (you asked about a feature no plan
declares, under undeclared_feature_policy: :raise) and
AuroraMeter.Period.InvalidPeriodError (your period source returned
something that is not a period) are host configuration errors and are
re-raised with their original stacktrace. So is anything your :tenant
resolver raises, and anything your AuroraMeter.Tenant module raises about
the term it returned: the plug resolves the tenant key before it takes the
decision, precisely so that a term your tenant module cannot handle reaches
your error handler rather than becoming a 503 that sends an operator to look
at a database that is perfectly well.
Everything else raised while taking the decision, a database that cannot be
reached during a cold counter seed being the one that happens, is logged at
:error and becomes the unavailable path, so a 503 is never silent.
Mounting it twice
Harmless and pointless. The decision is idempotent, consumes nothing and writes nothing, so a plug mounted in a pipeline and again in a controller takes it twice and assigns twice.
Compiled only when Plug.Conn is available. A host that adds plug after
compiling aurora_meter needs mix deps.compile aurora_meter --force.
Summary
Functions
Takes the decision and either assigns and continues, or halts.
Validates and freezes the options. Raises NimbleOptions.ValidationError on
an unknown option, a missing :feature or :tenant, or a :feature that is
not an atom.
Types
Functions
@spec call(Plug.Conn.t(), map()) :: Plug.Conn.t()
Takes the decision and either assigns and continues, or halts.
Never reserves, never holds credit and never writes.
Validates and freezes the options. Raises NimbleOptions.ValidationError on
an unknown option, a missing :feature or :tenant, or a :feature that is
not an atom.