Handling AUTH_RATE_LIMIT without locking real users out

Which auth errors are retryable, why refresh-too-frequent is a client bug rather than a limit, and a backoff that fails closed on the user's side, not yours.

Two Infrai auth errors look like the same problem and need opposite responses. AUTH_RATE_LIMIT means too many attempts against an address or an account and deserves a backoff. AUTH_REFRESH_TOO_FREQUENT means your client is calling POST /v1/auth/session/refresh on a timer instead of on expiry — retrying that harder makes it worse, and the fix is in your code, not in a retry policy.

Get the distinction wrong and you’ll ship a client that hammers refresh, trips the guard, retries, and locks a user out of an account whose credentials were perfectly fine.

What each error is telling you

Every Infrai error carries a typed code, an HTTP status and a retryable flag. Read the flag rather than inferring from the status:

curl -sS -i -X POST "https://api.infrai.cc/v1/auth/session/refresh" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "au_rft_definitely_not_valid"}'
{
  "ok": false,
  "error": {
    "code": "AUTH_TOKEN_INVALID",
    "http_status": 401,
    "message": "refresh token invalid",
    "docs_url": "https://docs.infrai.cc/errors/AUTH_TOKEN_INVALID",
    "retryable": false,
    "hint": "JWT verification failed."
  }
}

retryable: false. Retrying a bad token produces the same answer forever, so a client that loops here is burning your rate budget to learn nothing.

CodeMeaningRetry?Where the fix belongs
AUTH_RATE_LIMITtoo many attempts for this address/accountyes, with backoffclient cooldown, captcha in front
AUTH_REFRESH_TOO_FREQUENTrefresh called faster than intendednorefresh on expiry, not on a timer
AUTH_TOKEN_INVALIDtoken bad or already rotatednore-authenticate the user
AUTH_CODE_INVALIDwrong or expired OTPnoask for a fresh code
AUTH_INVALID_CREDENTIALSwrong email or passwordnoshow one generic message
RATE_LIMIT_ACCOUNTyour account’s overall budgetyes, with backoffqueue the work, spread the load

Refresh on expiry, not on an interval

The bug that produces AUTH_REFRESH_TOO_FREQUENT is almost always this: someone sets a setInterval to refresh every minute because it felt safe. Ten tabs open means ten refreshers. A mobile app resuming from background fires them all at once.

Refresh when the token is nearly expired, and let exactly one caller do it.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

let current = { accessToken: null, refreshToken: null, expiresAt: 0 };
let inFlight = null;

// Refresh only inside the last 60 seconds of the token's life, and never twice
// concurrently: a single in-flight promise collapses a burst of callers into one
// request, which is what stops a background-resume storm tripping the guard.
export async function accessToken() {
  const now = Date.now();
  if (current.accessToken && now < current.expiresAt - 60_000) return current.accessToken;
  if (inFlight) return inFlight;

  inFlight = (async () => {
    const res = await fetch(`${API}/v1/auth/session/refresh`, {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify({ refresh_token: current.refreshToken }),
    });
    const payload = await res.json();
    if (!payload.ok) {
      const code = payload.error?.code;
      // Not retryable: the session is over. Send the user back to sign-in
      // rather than looping.
      if (code === "AUTH_TOKEN_INVALID" || code === "AUTH_TOKEN_REVOKED") {
        current = { accessToken: null, refreshToken: null, expiresAt: 0 };
        throw new Error("reauthenticate");
      }
      throw new Error(code ?? "refresh_failed");
    }
    const data = payload.data;
    current = {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresAt: Date.now() + data.expires_in * 1000,
    };
    return current.accessToken;
  })().finally(() => { inFlight = null; });

  return inFlight;
}

Note that the new refresh_token replaces the old one. Rotation means a stored stale token becomes a guaranteed AUTH_TOKEN_INVALID on the next attempt, and two tabs each holding their own copy is how that happens.

Backoff that protects the user, not just you

For the genuinely retryable cases, jittered exponential backoff with a ceiling:

import os
import random
import time

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
RETRYABLE = {"AUTH_RATE_LIMIT", "RATE_LIMIT_ACCOUNT", "RATE_LIMIT_USER", "VENDOR_TIMEOUT", "NETWORK_ERROR"}
MAX_SLEEP_SECONDS = 45


def send_login_code(email: str, attempts: int = 5) -> dict:
    """Retry only what the platform marks retryable, and cap the wait so a
    batch never parks for a quarter of an hour on one address."""
    for attempt in range(attempts):
        resp = requests.post(
            f"{API}/v1/auth/email/send_code",
            headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
            json={"email": email, "purpose": "login"},
            timeout=15,
        )
        body = resp.json()
        if body.get("ok"):
            return body["data"]
        code = body.get("error", {}).get("code")
        if code not in RETRYABLE:
            raise RuntimeError(f"not retryable: {code}")
        retry_after = resp.headers.get("retry-after")
        delay = float(retry_after) if retry_after else min(MAX_SLEEP_SECONDS, 2 ** attempt)
        time.sleep(delay + random.uniform(0, 0.5))
    raise RuntimeError("retries exhausted")


if __name__ == "__main__":
    print(send_login_code(os.environ.get("LOGIN_EMAIL", "ada@example.com")))

Two details earn their place. Retry-After is honoured when present, because the platform knows better than your formula. And the ceiling is 45 seconds — without one, the fifth doubling parks a worker for minutes and the tenth parks it for a quarter of an hour, which turns a rate limit into an outage of your own making.

Don’t punish the user for your budget

The failure mode worth designing against: an account-level limit gets hit by a batch job, and the login path shares the budget, so real people can’t sign in while your backfill runs.

Two ways out. Separate keys per workload — POST /v1/account/keys/create gives you one for interactive traffic and one for batch — so a runaway job can’t starve sign-in. And move bulk work off the request path onto POST /v1/queue/publish, where the consumer sets the pace.

Neither needs a new vendor. Both are on the key you already have: the queue that absorbs the burst is POST /v1/queue/publish on this same account, needing no second signup, and GET /v1/account/usage on that same key tells you which of your keys burned the budget — one bill, one usage view, so “which workload caused this” is a query rather than a reconciliation across two providers’ exports.

The limitation

Rate limits here aren’t configurable per route from the API — you can’t raise the OTP ceiling for a marketing burst or lower it for a suspicious region. If you need per-endpoint limit policies as a product feature, Auth0 exposes far more of that, and for an app whose abuse profile needs tuning weekly it’s the better fit. Nor is there a webhook for “this address is being hammered”; you’d watch it yourself via POST /v1/metrics/report and alert from there.

Auth routes report billing_class: free in discovery, so retries don’t cost you per call — though an SMS OTP retry does spend real money on the message. Read the live per-route rate from GET /v1/discovery and your accrued spend from GET /v1/account/usage (verified 2026-09-21), and expect those rates to keep drifting downward.

References

Browse more auth developer guides