Errors & Retries
An error means the call did not complete. Errors are not charged.
Error format
Where the error shows up depends on when the call fails.
Before the tool runs (missing, invalid, or deleted API key): the HTTP request fails with status 401 and this body:
json
{ "code": "UNAUTHENTICATED", "message": "invalid token" }During the shopping.agent call: the HTTP request succeeds and the tool result has isError: true. structuredContent holds the error, and the text content holds the same object as JSON:
json
{
"error": {
"code": "RATE_LIMITED",
"message": "developer interval",
"request_id": "f689919f-2482-4cf7-a3b7-4757c1a20839",
"retryable": true,
"details": { "retry_after_seconds": 7 }
}
}| Field | Description |
|---|---|
code | Stable error code from the table below. Branch on this. |
message | Short English description. It can change; do not parse it. |
request_id | The call's request_id. Quote it when you contact support. |
retryable | true when retrying the same request later can succeed. |
details | { "retry_after_seconds": N } for rate limits, otherwise null. |
Error codes
| Code | Cause | retryable | What to do |
|---|---|---|---|
UNAUTHENTICATED | Missing, invalid, or deleted API key. Returned as HTTP 401. | No | Check the Authorization header. Create a new key in the console if needed. |
ACCOUNT_DISABLED | The account is disabled. | No | Contact support@commerceai.ai. |
FORBIDDEN | The request was blocked by abuse protection. | No | Contact support@commerceai.ai with the request_id if you think this is a mistake. |
INSUFFICIENT_CREDITS | Your balance is too low to start a turn. | No | Top up in Billing, or wait for the monthly free credits. |
INVALID_ARGUMENT | Bad or unknown parameter; selected_item_ids without conversation_id, with more than 10 or duplicate values, or with a value that is not valid for this conversation; an image or image URL in query (message is "images are not supported"). | No | Fix the request. For stale item_id values, search again to get fresh ones. |
IDEMPOTENCY_CONFLICT | The request_id was already used for a completed turn. | No | Send a new request_id, or omit it. The earlier turn is not run again or charged again. |
NOT_FOUND | conversation_id is unknown or belongs to another account. | No | Omit conversation_id to start a new conversation. |
CONVERSATION_EXPIRED | The conversation expired. See Conversations. | No | Omit conversation_id to start a new conversation. |
RATE_LIMITED | You exceeded your account's rate limits. | Yes | Wait details.retry_after_seconds, then retry. See Limits & Boundary. |
SHARED_CAPACITY | The service is at capacity. | Yes | Wait details.retry_after_seconds, then retry. |
SERVICE_UNAVAILABLE | A dependency, such as the balance service, is temporarily unavailable. | Yes | Retry with backoff. |
UPSTREAM_TIMEOUT | The shopping agent took too long. | Yes | Retry once. |
UPSTREAM_ERROR | The shopping agent failed. | No | Retry at most once; if it fails again, report the request_id. |
Retry safely
- Retry only when
retryableistrue, plus at most one retry forUPSTREAM_ERROR. - When
details.retry_after_secondsis present, wait at least that long. Otherwise use exponential backoff with jitter starting at 2 seconds. Stop after 3 attempts. - Do not retry the other codes. They fail the same way until you change the request, the key, or the balance.
- Each
request_idruns one turn. After a turn completes, reusing its id returnsIDEMPOTENCY_CONFLICT. If a turn fails before it completes, its id is freed and you can retry with the same id. Omittingrequest_idon retries is also safe.
python
import asyncio
import random
async def call_with_retry(session, args, attempts=3):
for attempt in range(attempts):
result = await session.call_tool("shopping.agent", args)
if not result.isError:
return result.structuredContent
error = (result.structuredContent or {}).get("error") or {}
last = attempt == attempts - 1
retry = error.get("retryable") or (error.get("code") == "UPSTREAM_ERROR" and attempt == 0)
if not retry or last:
raise RuntimeError(f"{error.get('code')}: {error.get('message')} ({error.get('request_id')})")
wait = ((error.get("details") or {}).get("retry_after_seconds")
or 2 * 2**attempt)
await asyncio.sleep(wait + random.random())