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.
- 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. - 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.
- 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.
- 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:
- Inbox creation can return
503 provider_unavailablewithretryable: false. When the provider’s side of an address could not be confirmed, the address is held and the message reads “Contact support with this request ID before retrying.” Retrying here risks exactly the orphaned state the server is trying to protect you from. - A deferred inbox creation depends on cleanup. If the reservation was released,
it is a retryable
rate_limited. If not, it isprovider_unavailablewithretryable: falseanddetails.reservation_released: false.
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.
| Code | Status | Retry same request? | What the agent does instead |
|---|---|---|---|
rate_limited | 429 | yes, after retry-after | Wait, then resend with the same key |
provider_unavailable | 503 | only if retryable: true | Back off; if false, stop and contact support with the request ID |
temporarily_unavailable | 503 | yes, bounded | Back off; on a send, re-read the message rather than switching keys |
attachment_not_ready | 422 | yes, once scan is clean | Poll the upload; resend with the same key once scan_status is clean |
domain_not_ready | 422 | after verification | Inbox creation only: finish domain verification, then create the inbox again |
version_conflict | 412 | not as-is | Re-read the resource, then decide |
invalid_request | 400 | never | Fix the fields in details.fields; hand the correction back to the model |
invalid_cursor | 400 | never | Drop the cursor and list from the first page |
cursor_expired | 410 | never | Restart the event scan from the beginning, deduplicating on event ID |
payload_too_large | 413 | never | Shrink the body or attachments |
recipient_suppressed | 422 | never | Drop that recipient and tell the model the address cannot be mailed |
not_found | 404 | never | Re-list to get a real ID; the resource may be outside the credential’s scope |
idempotency_conflict | 409 | never | Bug in key derivation. Log and stop; never retry with a fresh key |
idempotency_expired | 409 | never | Past the 30-day replay window; decide deliberately whether a new send is right |
address_unavailable | 409 | never | Pick a different address |
reserved_address | 422 | never | Pick a different local part |
test_mode_unsupported | 422 | never | Remove the live-only field (for example local_part on a test project) |
test_mode_required | 403 | never | Point the simulation call at a test inbox |
test_limit_exceeded | 403 | never | Free test capacity; the cap will still be there |
unauthenticated | 401 | never | Human: the credential is missing, revoked or expired |
permission_denied | 403 | never | Human: widen the grant, or accept that this agent may not do this |
policy_denied | 403 | never (see reason_codes) | Human: a policy refused the send; report it, do not rephrase and resend |
payment_required | 402 | never | Human: the workspace lacks the entitlement |
quota_exceeded | 402 | not until usage drops | Human: 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:
- The provider call returned an outcome that proves neither acceptance nor
rejection. The message state becomes
submission_unknownand amessage.submission_unknownevent is emitted. - A worker died mid-submission. The next lease finds the internal send intent still
marked
submitting, recordssubmission_unknownwithinterrupted_submission, holds the quota, and returns, without emitting an event. The message itself is never set tosubmitting, so do not wait for your listing to show it.
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.
- Any non-API exception during a tool call becomes one generic string. “Invalid tool
arguments or unavailable content.” covers a schema failure in the arguments, a missing
or over-length
idempotency_key(it must be 1 to 200 characters), and any unexpected internal fault. A model that reads that as “my arguments were wrong” and rewrites the email has turned a possible server fault into a different message. - Failures before dispatch come back as HTTP errors, not tool results. Rate limiting, a revoked connection, or calling a tool the grant does not include (“This connection cannot use that tool.”) are thrown before the tool runs, so they reach the client as an HTTP status with the full envelope. An invalid or expired access token is refused even earlier, by the OAuth layer, with a standard OAuth 401 rather than this envelope. How your MCP client surfaces either to the model varies by client.
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
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Your Agent Sent the Same Email Twice. Why
Four reasons an agent sent the same email twice, each with a different fix: a keyless retry, a replayed event, two turns, and a second approval.
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.