Developers · September 28, 2026

Retry, Backoff and Error Handling for Agent Email

Agent email error handling that sorts every error by whether a retry can help: a decision table, what backs off, and what goes to a human.

An agent’s send fails and the wrapper does what every backoff guide says: one second, two, four, five attempts, jitter on top. If the error was a 403 because a human switched sending off for that inbox, the agent has spent thirty seconds hammering a decision. If the error was a 422 because it made up a recipient, the fifth attempt fails exactly like the first. And if the first attempt timed out after the server committed it, the retry can return an error for an email the server has already queued.

This article is about agent email error handling: retries, backoff, and above all which errors a retry can fix at all. It sorts every code into four classes in one decision table. The mechanics of the Idempotency-Key itself (canonical payload hashing, the 30-day replay window, the four outcomes of reusing a key) are covered in idempotency keys: stop an agent sending duplicate emails, and this piece links there rather than repeating them.

Four classes, sorted by what a retry can change

The question is not “is this error transient” but “can sending the identical request again ever produce a different answer”. Every error falls into one of four classes, and only the first deserves a backoff loop.

  1. Retry the same request. The fault is in transport or capacity, and nothing about your request is wrong. Same body, same Idempotency-Key, wait, try again.
  2. Wait for a condition, then retry the same request. The request is correct but a precondition has not been met yet: an attachment scan still pending, a domain still verifying. Polling the condition beats blind backoff, because you know what you are waiting for.
  3. Change something, or stop. The request itself is wrong (a bad field, an address the agent invented, a key reused with a different body). Retrying the same bytes fails forever. The fix belongs in the payload or in your code, and a genuinely changed message is a new message with a new key.
  4. Hand it to a human. The request is fine and the system is fine, but authority is missing: a scope nobody granted, a policy someone set, an allowance that ran out. No amount of waiting or rephrasing changes that, and an agent that keeps trying to route around it is doing the one thing the control exists to prevent.

There is a fifth situation that is not an error at all, and it is the dangerous one: the request succeeded but you did not hear back. That gets its own section below.

The error envelope tells you whether you are in class 1, in a field:

{
  "error": {
    "code": "policy_denied",
    "message": "Workspace policy blocked this send.",
    "request_id": "req_...",
    "retryable": false,
    "details": { "reason_codes": ["send_disabled"] }
  }
}

retryable defaults to true for exactly three codes, rate_limited, provider_unavailable and temporarily_unavailable, and false for everything else. The next section is about why you should read the field and not the code.

Read retryable on each response, not the code or the status

A 503 is not automatically retryable, and the envelope says so when it is not. The default can be overridden per response, and this build does override it:

Going the other way, a retryable: true can describe a fault that will not go away. Any exception the API does not map to a named code is returned as 503 temporarily_unavailable, “The service is temporarily unavailable.”, and that code defaults to retryable. A deterministic server-side bug looks, from the outside, identical to a blip. That is the case for a bounded retry budget: honour retryable: true, but stop after a handful of attempts and escalate with the request IDs rather than looping for an hour.

So the classifier reads, in order: retryable, then code, then details. The HTTP status is only for your dashboards.

The decision table for every error code

The error vocabulary declares 29 codes. Three have no throw site and are never returned: daily_limit_exceeded, source_unavailable and send_already_submitting. Two more, last_owner_required and environment_immutable, are raised by database triggers that no route converts into the envelope, so you will not see them in error.code. The daily cap returns policy_denied and the in-flight send case returns 503 temporarily_unavailable, so match on the 24 real codes below. Every trigger for each code is in the error code reference.

CodeStatusRetry same request?What the agent does instead
rate_limited429yes, after retry-afterWait, then resend with the same key
provider_unavailable503only if retryable: trueBack off; if false, stop and contact support with the request ID
temporarily_unavailable503yes, boundedBack off; on a send, re-read the message rather than switching keys
attachment_not_ready422yes, once scan is cleanPoll the upload; resend with the same key once scan_status is clean
domain_not_ready422after verificationInbox creation only: finish domain verification, then create the inbox again
version_conflict412not as-isRe-read the resource, then decide
invalid_request400neverFix the fields in details.fields; hand the correction back to the model
invalid_cursor400neverDrop the cursor and list from the first page
cursor_expired410neverRestart the event scan from the beginning, deduplicating on event ID
payload_too_large413neverShrink the body or attachments
recipient_suppressed422neverDrop that recipient and tell the model the address cannot be mailed
not_found404neverRe-list to get a real ID; the resource may be outside the credential’s scope
idempotency_conflict409neverBug in key derivation. Log and stop; never retry with a fresh key
idempotency_expired409neverPast the 30-day replay window; decide deliberately whether a new send is right
address_unavailable409neverPick a different address
reserved_address422neverPick a different local part
test_mode_unsupported422neverRemove the live-only field (for example local_part on a test project)
test_mode_required403neverPoint the simulation call at a test inbox
test_limit_exceeded403neverFree test capacity; the cap will still be there
unauthenticated401neverHuman: the credential is missing, revoked or expired
permission_denied403neverHuman: widen the grant, or accept that this agent may not do this
policy_denied403never (see reason_codes)Human: a policy refused the send; report it, do not rephrase and resend
payment_required402neverHuman: the workspace lacks the entitlement
quota_exceeded402not until usage dropsHuman: an allowance (sending, storage, exports) is spent

policy_denied is a decision, not a fault. It carries details.reason_codes: send_disabled, blocked_recipient, recipient_not_allowed, or unsupported_policy_rule. The daily recipient cap also returns policy_denied, with no reason_codes and the message “The daily recipient limit has been reached.” The cap buckets are keyed by UTC date, so that one clears at midnight UTC, but it is still not something a backoff loop should wait out.

permission_denied can fire before the agent ever runs. Creating a key that requests a scope its creator lacks fails at creation with “A credential cannot grant permissions its creator does not hold.” At use time the message is “This credential cannot perform that action.” Neither changes until a human changes a grant.

Two 422s are class 2, not class 3. attachment_not_ready and domain_not_ready describe a request that will succeed once something finishes. Poll the thing, not the send. An attachment ID is part of the canonical payload, so the eventual retry under the same key is the same message.

Retries and backoff for the codes that deserve it

Back off exponentially with jitter, honour retry-after, and cap the attempts. Two details specific to this API change how.

retry-after is a constant 60, not a computed wait. Every rate_limited response gets retry-after: 60 from the API and MCP error handlers, and the edge limiter sets the same value. That is accurate for the request budgets, which are 600 requests per 60 seconds per principal and per workspace, behind a per-IP edge budget of 1,200 per 60 seconds. The counters are per-location and permissive, so treat them as a ceiling to stay under, not a quota to spend. But the same header rides on narrower limits too. Export requests are capped at two per member per hour (“At most two exports can be requested per hour per member.”), and the header still says 60. A client that obeys it literally will retry dozens of times before the hour is up. Branch on the message, or treat a second rate_limited on the same operation as class 4.

The server’s own backoff is a reasonable template. Provider submission retries behind the API start at one second, double to a five-minute cap, add up to a second of jitter, and are bounded by a 23-hour deadline. Sends are queued and the platform runs that loop, so yours only has to get the request accepted.

import logging, os, random, time, uuid
import requests

API_KEY = os.environ["EFA_API_KEY"]
TRANSPORT_ERRORS = (requests.Timeout, requests.ConnectionError)
log = logging.getLogger("efa.send")

class TerminalError(Exception): pass        # class 2, 3 or 4: do not resend
class NeedsReconciliation(Exception): pass  # may have been sent: read, do not resend
class GaveUp(Exception): pass               # budget spent; server never took it

def backoff(attempt: int) -> float:
    return min(300, 2 ** attempt) + random.random()

def submit_send(session, url, key, body, max_attempts=5):
    """Returns the 202 body, or raises. `key` is derived once, outside this loop."""
    outcome_unknown = False
    for attempt in range(max_attempts):
        rid = f"agent-{uuid.uuid4()}"
        try:
            r = session.post(url, json=body, timeout=30, headers={
                "Authorization": f"Bearer {API_KEY}",
                "Idempotency-Key": key,
                "x-request-id": rid,
            })
        except TRANSPORT_ERRORS:
            outcome_unknown = True          # the server may have committed it
            time.sleep(backoff(attempt))
            continue
        if r.status_code == 202:
            return r.json()                 # queued, approval_required, or a replay
        try:
            err = r.json().get("error", {})
        except ValueError:
            err = {"code": None, "retryable": r.status_code >= 500}
        log.warning("send key=%s request_id=%s status=%s code=%s", key,
                    r.headers.get("x-request-id"), r.status_code, err.get("code"))
        if r.status_code >= 500:
            outcome_unknown = True          # includes "a matching send is already in progress"
        if err.get("retryable"):
            time.sleep(float(r.headers.get("retry-after") or backoff(attempt)))
            continue
        if outcome_unknown:
            raise NeedsReconciliation(key, err)   # do not treat as "not sent"
        raise TerminalError(err)
    raise NeedsReconciliation(key, None) if outcome_unknown else GaveUp(key)

The outcome_unknown flag is the part generic guides leave out, and the next section is why it exists.

A retry can fail even though the first attempt worked

On this API, the checks that can refuse a send run before the idempotency replay. The send path checks scope, validates recipients and evaluates policy first, and only then looks up the idempotency record that would replay the original response. If nothing changed between attempts, that ordering is invisible: the retry passes the same checks and gets the original 202 back.

If something did change, it is not invisible. Say the first attempt reached the server, committed, and the response was lost to a timeout. While your client backs off, an operator switches sending off for that inbox. The retry now returns 403 policy_denied with send_disabled, and a naive handler records “send failed” for a message the first attempt already queued. Whether that message then goes out depends on timing: the submission worker re-checks authority and policy before calling the provider, so if the change landed first the message ends up canceled (with no event), and if not it is already with the provider. The same happens if the key was revoked (401 unauthenticated) or its scope narrowed (403 permission_denied) in the gap.

So a non-retryable error after a transport failure does not mean the message was not sent. It means you do not know. That is why the loop above raises NeedsReconciliation in that case instead of a plain failure, and why the agent must not be told “that didn’t work, try again”. Reconcile by reading, not by sending: GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages returns each message with its direction and state, so an outbound message with the right subject from the last few minutes is your answer, and its state says whether it is still going. If you cannot tell, escalate to a human with the key and every request ID you logged.

The inverse also holds, and it is the reason the key must be stable: if the first attempt never reached the server, the retry under the same key is a clean first submission. The same key is safe in both worlds, and a fresh key is safe in neither. Why an agent sends the same email twice walks through what a fresh key per attempt does in production.

When the server accepted the send but the outcome is unknown

A 202 from the send endpoint means the server took the request, not that a provider took the message. Read result: "queued" (with state: "queued", a message_id and a request_id) means the message is stored and waiting for submission, and "approval_required" means it is held for a human and nothing has been sent. Provider faults on a send therefore never come back as an HTTP error on your request. They show up later as message state and events: message.accepted, message.failed, or message.submission_unknown.

message.failed is permanent for that message, and so is a recipient’s server refusing it after acceptance, which arrives as message.delivery_updated. Neither is a retry signal, and rewording the draft to get past a rejection is worse than stopping; what to do when agent email bounced 550 5.7.1 covers why.

submission_unknown is the class the platform refuses to retry for you, and your code must not retry it either. There are two ways a message reaches it, and they differ in one way that matters:

If your agent only reacts to events, the second path is silent. Poll message state for anything that has not reached accepted or failed when you expect it to, rather than assuming an event will arrive. In both cases the answer is the same: do not create a new send. Surface it as its own state, not as “failed”, because someone on call will press resend on a message that already went out.

Do not wait for it to clear, either. In this build nothing moves a message out of submission_unknown on its own. Provider callbacks are matched to a send by the provider’s email ID, and that ID is stored only when the provider’s reply was classified as accepted, so a later delivery or bounce callback for an unknown send matches nothing. No job reconciles it. A poll that waits for accepted will wait forever; treat the state as final for your code and hand it to a person, who can check the recipient side before deciding whether a new send is warranted. Does anything resolve it later? walks through the code path. Accepted is also not delivered; accepted versus delivered covers what each state proves, and the event reference is in the events and webhooks docs.

Error handling over MCP: the model sees a sentence, not a code

When a tool call fails inside the MCP server, the agent gets isError: true and the error’s message text, with no code and no retryable flag. Everything in the decision table above has to be recovered from a sentence.

Give the agent a rule per message instead of “retry on failure”. “A matching send is already in progress.” means wait and check the thread, never send again. “Workspace policy blocked this send.” means stop and tell the user. “Idempotency-Key was reused with different content.” (or “with a different payload.”, depending on the path) means the key and the draft disagree, which over MCP is usually the model choosing its own key, and it should not be. For the full envelope, call the REST API from your own tool wrapper; both surfaces are described on the developer platform page.

Log the request ID on every failure, including your own

REST API responses carry an x-request-id header, and the same value appears as request_id in the error envelope and in the 202 body. It is the one identifier support can correlate with server logs. Log it with the idempotency key and the error code on every attempt, not only the last.

If you send your own x-request-id on a REST request, the current code uses it instead of generating one, echoes it back in the header, and puts it in the envelope. That joins your agent trace to the server’s view, which is why the loop above sets a unique one per attempt. Reusing one across retries makes the attempts indistinguishable. Over MCP the header is echoed too, but some error envelopes carry an ID the worker generated separately, so log both the header and error.request_id.

One exception: a 429 or 503 from the edge limiter is generated before the API runs, with its own random ID in both the header and the envelope. If your logged ID does not appear in a support conversation, check whether the response came from the edge.

Those three fields per attempt (idempotency key, request ID, error code) answer the only question that matters in an incident: which class was it, and did anything go out? The same envelope shapes, applied across the whole send-receive-reply loop, are in the agent email API loop.


Keep reading

All guides · Documentation · Developers