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
end

Options

OptionTypeDefaultMeaning
:featureatom, requirednonethe feature to check; a binary is refused at compile time
:tenant(Plug.Conn.t() -> tenant | nil) or {module, function}, requirednoneyour 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_quotabooleantrueassign :aurora_meter_quota on a passing request
:on_missing_tenant:unauthorized | {module, function}:unauthorized401, reason :missing_tenant
:on_denied:forbidden | {module, function}:forbidden403, reason :not_entitled or :limit_exceeded
:on_unavailable:service_unavailable | {module, function}:service_unavailable503, 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

Types

Why the plug halted.

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

denial()

@type denial() :: :missing_tenant | :not_entitled | :limit_exceeded | :unavailable

Why the plug halted.

Functions

call(conn, opts)

@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.

init(opts)

@spec init(keyword()) :: map()

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.