Skip to content

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 }
  }
}
FieldDescription
codeStable error code from the table below. Branch on this.
messageShort English description. It can change; do not parse it.
request_idThe call's request_id. Quote it when you contact support.
retryabletrue when retrying the same request later can succeed.
details{ "retry_after_seconds": N } for rate limits, otherwise null.

Error codes ​

CodeCauseretryableWhat to do
UNAUTHENTICATEDMissing, invalid, or deleted API key. Returned as HTTP 401.NoCheck the Authorization header. Create a new key in the console if needed.
ACCOUNT_DISABLEDThe account is disabled.NoContact support@commerceai.ai.
FORBIDDENThe request was blocked by abuse protection.NoContact support@commerceai.ai with the request_id if you think this is a mistake.
INSUFFICIENT_CREDITSYour balance is too low to start a turn.NoTop up in Billing, or wait for the monthly free credits.
INVALID_ARGUMENTBad 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").NoFix the request. For stale item_id values, search again to get fresh ones.
IDEMPOTENCY_CONFLICTThe request_id was already used for a completed turn.NoSend a new request_id, or omit it. The earlier turn is not run again or charged again.
NOT_FOUNDconversation_id is unknown or belongs to another account.NoOmit conversation_id to start a new conversation.
CONVERSATION_EXPIREDThe conversation expired. See Conversations.NoOmit conversation_id to start a new conversation.
RATE_LIMITEDYou exceeded your account's rate limits.YesWait details.retry_after_seconds, then retry. See Limits & Boundary.
SHARED_CAPACITYThe service is at capacity.YesWait details.retry_after_seconds, then retry.
SERVICE_UNAVAILABLEA dependency, such as the balance service, is temporarily unavailable.YesRetry with backoff.
UPSTREAM_TIMEOUTThe shopping agent took too long.YesRetry once.
UPSTREAM_ERRORThe shopping agent failed.NoRetry at most once; if it fails again, report the request_id.

Retry safely ​

  • Retry only when retryable is true, plus at most one retry for UPSTREAM_ERROR.
  • When details.retry_after_seconds is 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_id runs one turn. After a turn completes, reusing its id returns IDEMPOTENCY_CONFLICT. If a turn fails before it completes, its id is freed and you can retry with the same id. Omitting request_id on 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())