docs changelog blog status pricing get key →
// blog

When agents hit your 429 without reset_at, things get bad fast

2026-04-28 · by Guilherme Secca

truval.dev — agent-callable APIs
Stable error contracts, reset_at, and Retry-After

A 429 without a machine-readable reset timestamp doesn't just slow agents down. Under the right conditions it turns them into a coordinated attack on your own API.

Think through what happens: 50 agents running generated code against the same SDK all hit a rate limit at roughly the same time. None of them gets a reset_at. So each one does the sensible thing and starts exponential backoff with jitter. Except "jitter" across agents running identical generated code tends to cluster. They back off, then they all retry at roughly the same time, then they all hit the limit again. The thundering herd is your own clients. You don't need pathological load for this — you just need enough agents running the same generated retry logic.

This is what we designed around building Truval: API infrastructure built to be called by agents. Email verification is the first surface — more APIs will follow. The fix was straightforward:

HTTP 429 (rate limit)
{
  "error": "rate_limit_exceeded",
  "message": "Rate limit of 10 req/min exceeded.",
  "action": "Wait until reset_at (plus a small cushion) before retrying.",
  "limit": 10,
  "window": "1m",
  "reset_at": "2026-04-24T12:00:00.000Z",
  "docs": "https://docs.truval.dev/api/email-verify#rate-limits"
}

We also send Retry-After as an HTTP header — same idea, expressed as delta-seconds until reset_at — for clients that read it there. One edge case worth knowing: reset_at is a raw server-side timestamp with no built-in buffer. If a client's clock runs slightly ahead of ours, an early retry might land before the window resets and get another 429. The SDK adds 50ms; for generic clients without tight loops, 1-2s is a safe conservative default. Either way, sleep past reset_at rather than exactly on it — which is why the action field says "plus a small cushion."

Now retry logic is just: sleep until reset_at (plus a small cushion), then retry once. No backoff math. No jitter. No storm.

That one change is the most important thing I’d tell someone designing an API that agents will call. Everything else matters, but it’s downstream of “don’t make agents guess.”

Agents depend on contracts, not docs

When a developer integrates an API they read the docs, try things, adjust. The feedback loop is interactive. Agents don’t work that way. They ingest a machine-readable surface once, pick a path, and repeat it across every generated call forever. If anything in that path is ambiguous, they’ll invent a plausible answer. Wrong base URL, wrong auth header, a missing retry on a transient 503 that gets treated as permanent.

The contract an agent actually depends on is: base URL, auth shape, OpenAPI spec, and a small stable set of error codes with typed fields. Not the prose. Not the getting started guide.

For Truval this meant publishing three stable URLs and treating them like a public interface:

  • https://api.truval.dev — the base, not moving
  • GET https://api.truval.dev/openapi.json — source of truth for codegen
  • POST https://mcp.truval.dev/mcp — tool surface for MCP-compatible hosts

The OpenAPI spec is a compatibility boundary, same as a library interface. Renaming an operation casually is a breaking change. Adding a field is fine. Removing or renaming one is not.

The other errors worth getting right

Monthly quota hits need the same treatment as rate limits — reset_at, plus used and limit so agents can decide what to surface. reset_at is an exact boundary timestamp. (The “small cushion” matters most for short windows like per-minute rate limits. For monthly quota resets it’s midnight UTC, so being off by 1–2 seconds is irrelevant in practice.)

HTTP 429 (monthly quota)
{
  "error": "monthly_quota_exceeded",
  "message": "Monthly free tier limit of 500 verification units reached for this UTC month.",
  "action": "Upgrade your plan or wait until the next UTC month.",
  "limit": 500,
  "used": 500,
  "reset_at": "2026-05-01T00:00:00.000Z",
  "docs": "https://docs.truval.dev/api/email-verify#monthly-quota"
}

Billing blocks should carry the numeric fields that matter:

HTTP 402
{
  "error": "payment_required",
  "message": "Hard spend cap reached. Upgrade or raise your cap to continue.",
  "action": "Adjust your hard cap in Billing & Limits, or upgrade your plan.",
  "hard_cap_eur": 50,
  "current_overage_eur": 52.5,
  "docs": "https://docs.truval.dev/api/email-verify#spend-cap"
}

That lets a client render a real UI state rather than retrying forever on something that won’t resolve.

Quota check failures — when you genuinely can’t tell whether a request is within limits — should be a dedicated 503, not a generic 500. Agents need to treat it as transient:

HTTP 503
{
  "error": "quota_check_failed",
  "message": "Could not verify monthly usage quota. Retry shortly.",
  "action": "If this persists, contact support.",
  "docs": "https://docs.truval.dev/api/email-verify#monthly-quota"
}

Auth shape mismatches are the other common failure

Agents frequently mix up key types, especially when an API has more than one. For Truval there’s a verification key and a provisioning key — different prefixes, different permissions. Without a specific error, an agent with the wrong key will just retry with the same key and eventually give up.

The fix is a typed error that says exactly what went wrong and what to do:

Wrong key type
{
  "error": "wrong_key_type",
  "message": "This endpoint requires a verification key (sk_live_). You sent a provisioning key (sk_mgmt_).",
  "action": "Use your verification key for this request.",
  "docs": "https://docs.truval.dev/api/email-verify#authentication"
}

Retry semantics: make the policy explicit

  • Retry on 429 using reset_at. Sleep until then (plus a small cushion), retry once.
  • Retry on transient 5xx and network timeouts with a short fixed delay.
  • Never retry on invalid_request — it won’t change.
  • Never retry on payment_required or monthly_quota_exceeded — same.

If you ship an SDK, encode this directly in the client. Generated code will get it wrong otherwise.

The checklist

  • Canonical base URL, not moving
  • OpenAPI spec at a stable URL, treated as a compatibility boundary
  • Stable error codes with typed fields: reset_at, limit, used, hard_cap_eur
  • Retry-After header on all 429s matching the JSON reset_at
  • Explicit retry semantics consistent with how you actually enforce limits
  • MCP as a thin adapter over the same contract, not a separate API

The agents will hold you to all of it.

truval.dev is API infrastructure built to be called by agents and code.

← All posts