# AI Errors and Retries

> Every GET /v1/ai error status, what it costs in AI credits, how to retry it, the latency budget, and what to include in a support request.

Canonical: https://currency-core.com/docs/ai-errors/

---
`/v1/ai` uses the standard CurrencyCore error envelope, plus a set of `200`
responses that are not direct answers. This page separates the two, gives the
credit cost of each case, and sets a retry policy that does not burn credits.

## Error envelope

```json
{
  "error": {
    "code": "unsupported_query",
    "message": "This endpoint only answers currency conversion, exchange-rate, and PPP questions."
  }
}
```

`code` is stable and machine-readable. `message` is for humans. `details` is
optional and is not used by the AI route today.

## Error matrix

| Status | Code | When it happens | AI credits | Retry? |
|---|---|---|---:|---|
| `400` | `invalid_input` | `q` is missing or blank. | 0 | No. Send a question. |
| `401` | `unauthorized` | Key missing, invalid, or revoked. Revocation clears within 5 minutes because of the auth cache. | 0 | No. Fix the key. |
| `402` | `billing_past_due` | The organization's billing is past due. | 0 | No. Clear payment. |
| `403` | `forbidden` | AI is disabled for the organization, or disabled platform-wide. | 0 | No. Enable it in the dashboard, or contact support if the platform switch is off. |
| `413` | `invalid_input` | `q` or total model input exceeds the token budget. Rejected before the model. | 0 | No. Shorten the question. |
| `422` | `unsupported_query` | The question is off-topic or an attempt to change the assistant's behavior. The model was consulted. | 1 (native) | No. Reformulate as a currency, FX, or PPP question. |
| `429` | `rate_limited` | Per-minute rate limit, monthly request quota (BYOK tool-call units), or AI credit pool is exhausted. | 0 | Yes for the per-minute limit. For credits, wait or buy more. |
| `500` | `internal_error` | An unexpected server error. | 0 | Yes, with backoff. |
| `503` | `internal_error` | No provider configured, every provider unreachable, or BYOK required with fallback off but unusable. | 0 | Yes for a transient outage. Fix BYOK if it persists. |

A `429` from the per-minute rate limit includes `Retry-After` in seconds. A `429`
from an exhausted AI credit pool may not include it, because waiting is not what
fixes it. Read the message.

A native `422` is metered because the model did real work before declining. A
`413` is not metered because it is rejected before the model runs. `503` is not
metered. See [AI credits](/docs/ai-credits/) for the pool rules.

## A 200 is not always an answer

These cases return `200`. There is no `outcome` or `kind` field in the body today,
so inspect `data` and `answer`.

| Body | Meaning | What to do |
|---|---|---|
| `data` non-empty | A normal answer with structured results. | Render `answer`; use `data` for numbers. |
| `data: []`, answer asks for a missing detail | Clarification. For example, a salary question with no amount, or a multi-country currency with no default. | Ask the user for the detail, then send a new request. |
| `data: []`, answer explains missing data | In-scope question, but no rate, PPP factor, currency, or date in coverage. | Show the explanation. Do not retry the same question. |
| `data: []`, answer asks you to upgrade | A Free or Starter organization asked for Growth+ capability. | Upgrade, or ask a single-date question. |
| `data: []`, `toolCalls: 0`, answer asks you to narrow the request | The analysis hit the orchestration deadline. Not a `504`. | Narrow the window or the currency set. |
| `data: []`, answer declines a PPP ranking | Cross-country purchasing-power ranking is not answerable from PPP factors alone. | Ask for a pairwise PPP comparison instead. |
| `data: []`, answer gives a one-line topic refusal | A related financial topic, such as gold or oil, that the fiat-only data does not cover. | Show the refusal. Use a source that covers that asset. |
| `data: []`, answer is plain prose with no tool | The answer did not need a data lookup. | Treat it as informational, not as a source of numbers. |

A `200` with `data: []` still consumes one AI credit on the native path, because
the model was consulted. A too-large analysis with `toolCalls: 0` is the same:
one native credit.

## Retry policy

`GET /v1/ai` is safe to retry at the HTTP level, but a successful retry is a new
request. On the native path it consumes another AI credit. With BYOK it bills
tool calls again. Do not retry a `200` just because you did not like the answer.

```ts
type RetryDecision = "retry" | "do-not-retry";

export function retryDecision(status: number): RetryDecision {
  if (status === 429 || status === 500 || status === 503) return "retry";
  return "do-not-retry";
}

export async function askWithRetry(
  q: string,
  key: string,
  maxAttempts = 3,
): Promise<Response> {
  let last: Response | undefined;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const url = new URL("https://api.currency-core.com/v1/ai");
    url.searchParams.set("q", q);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${key}` },
      signal: AbortSignal.timeout(30_000),
    });

    if (res.ok) return res;
    last = res;

    if (retryDecision(res.status) === "do-not-retry") return res;

    const retryAfter = Number(res.headers.get("Retry-After"));
    const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1_000
      : Math.min(8_000, 500 * 2 ** (attempt - 1)) + Math.random() * 250;

    if (attempt === maxAttempts) return res;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }

  return last!;
}
```

Rules:

- **Do not retry** `400`, `401`, `402`, `403`, `413`, or `422`. The request will
  fail the same way, and a native `422` costs a credit each time.
- **Retry `429`** only after `Retry-After`. If the message says the AI credit
  balance is exhausted, stop and buy credits, wait for the cycle, or use BYOK
  with fallback disabled.
- **Retry `500` and `503`** with jittered exponential backoff, a few attempts.
- **Do not retry a client-side timeout** without a new decision. The server can
  take up to 15 seconds per attempt, and a lost response may still have been
  metered.
- **Treat a `200` as final.** If you need a different window or shape, send a new
  prompt, not a retry of the same one.

## Latency budget

| Budget | Default | Notes |
|---|---|---|
| Orchestration deadline | 15 seconds per attempt | `AI_TIMEOUT_MS` can change it for a deployment. |
| Per-provider timeout | 8 seconds | `AI_PROVIDER_TIMEOUT_MS` can change it. Must stay below the orchestration deadline. |
| Attempts | Up to 3 | Only for transient provider unavailability (network, 429, 5xx). Deterministic errors and deadline timeouts are not retried. |
| Client timeout | 30 seconds suggested | Covers the 15-second deadline plus retries and network overhead. |

If the orchestration deadline is reached and the model produced usable text, the
request can still return `200`. If it produced no usable answer, the route
returns a structured `200` asking you to narrow the request, not a `504`. A
genuine infrastructure failure returns `503`.

There is no published p50 or p95 latency commitment. Simple conversions are
normally faster than multi-currency analytics. A request that fans out to many
currencies or a long window is the most likely to hit the deadline.

## Request IDs

There is **no request ID header** on `/v1/ai` today. Do not look for
`x-request-id` or a `requestId` body field.

The route does return diagnostic headers you can capture:

- `x-currencycore-ai-byok`
- `x-currencycore-ai-provider`
- `x-currencycore-ai-attempts`
- `x-currencycore-ai-tools`
- `x-currencycore-ai-fast`
- `x-currencycore-ai-ms`
- `Server-Timing`

These identify routing and timing, not a specific log record. For support, pair
them with the UTC time.

## Support checklist

Include all of the following in a support request. Do not include the full API
key.

1. The UTC timestamp of the request, with the timezone.
2. The HTTP status, error `code`, and full error `message`.
3. The exact `q` value, or a minimal version that still reproduces the issue.
4. The organization id, and the key prefix only (for example `cc_live_ab12`).
5. Whether BYOK was active, and which provider id.
6. The response headers listed above, especially `x-currencycore-ai-byok`,
   `x-currencycore-ai-provider`, and `x-currencycore-ai-attempts`.
7. The full response body if it was a `200` non-answer, with any sensitive text
   removed.
8. Whether the issue reproduces with the same `q`, and roughly how often.
9. For BYOK: the result of **Test connection**, the model id, and whether
   fallback is on. Do not send the provider key.

A support request that says only "the AI gave a wrong answer" is hard to act on.
A request that includes the `data` object, the `asOf` date, and the REST response
you compared against is actionable.

## Related

- [AI endpoint reference](/docs/ai-endpoint/)
- [AI quickstart](/docs/ai-quickstart/)
- [AI grounding](/docs/ai-grounding/)
- [AI credits](/docs/ai-credits/)
- [BYOK](/docs/byok/)
- [General API errors](/docs/errors/)
