---
title: Error codes & retry semantics
description: Complete list of error codes the API returns, when to retry, and how to back off. Only 2xx responses are billed.
order: 20
---

# Error codes

Every non-2xx response is free. That's the contract - if we didn't give you a clean product, you didn't pay for it.

All errors return a JSON body with the same shape:

```json
{
  "error": "string_code",
  "message": "human-readable explanation",
  "docs_url": "https://chocodata.com/docs/guides/errors#string_code",
  "request_id": "req_9f2c8a1b3e4d"
}
```

The `request_id` is what we'll ask for if you email support. `docs_url` deep-links to the row in this table.

## Error table

| HTTP | `error` code | When it happens | Retry? |
|---|---|---|---|
| `400` | `invalid_params` | Query identifier malformed, unsupported `domain` value, `language` not in the enum, missing `query` or `url`. | No - fix the request. |
| `401` | `unauthorized` | Missing or malformed `api_key`, or unknown key. | No - get a valid key. |
| `401` | `revoked` | Key was rotated out. | No - use your current key. |
| `402` | `insufficient_credits` | You're out of credits and have no active auto-top-up. | Yes, after topping up. |
| `404` | `not_found` | You hit an unknown path. Check the endpoint URL. | No. |
| `404` | `item_not_found` | The target site returned 404 for the requested item: it's delisted, the identifier is malformed, or it never existed in this region. The response includes `retryable: false`. | No - drop the item from your set, or try a different region. |
| `408` | `upstream_timeout` | The target site took > 45s to respond. | **Yes** - retry up to 3 times with 2s backoff. |
| `422` | `blocked_by_target` | The target site served a challenge page or 503. We already retried internally. | Yes, but with a 60s+ delay. |
| `429` | `rate_limited` | You exceeded your per-key RPS or concurrency ceiling. Check the `Retry-After` header. | **Yes** - respect `Retry-After`. |
| `500` | `internal_error` | Unhandled exception on our side. Always get logged. | Yes, after ~5s. |
| `501` | `not_implemented` | You passed a roadmap param (`render_js`, `screenshot`). | No - wait for the feature. |
| `502` | `target_unreachable` | The target site blocked every internal retry. | Yes, with a 60s+ delay. |
| `502` | `extraction_failed` | The target site served markup that our extractor couldn't parse. Usually a new layout; we auto-file a ticket. | Yes, typically works on retry. |
| `502` | `id_mismatch` | You asked for item A, the site redirected to item B (usually a swap to a newer version of the item). | No - the source identifier is what's stale. |
| `502` | `placeholder_page` | The target served a generic placeholder instead of the requested item (the item is delisted). | No - remove the item from your set. |
| `503` | `capacity` | We're rate-limiting you to protect the shared pool. Rare. | Yes, after `Retry-After`. |

## Retry semantics - the defaults we recommend

Use **exponential backoff with jitter** for anything marked "Yes" above. Here's a canonical loop you can port to any language:

```javascript
async function scrapeWithRetry(fetchFn, maxAttempts = 4) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fetchFn();
    if (res.ok) return res.json();

    // Don't retry permanent errors. (402 insufficient_credits is also
    // terminal until you top up - handle it separately if you auto-refill.)
    if ([400, 401, 404, 501].includes(res.status)) {
      throw new Error(`permanent: ${res.status}`);
    }

    if (attempt === maxAttempts) throw new Error(`gave up after ${attempt}`);

    // Respect our Retry-After if we sent one.
    const retryAfter = Number(res.headers.get("Retry-After")) * 1000;
    const backoff = retryAfter || Math.min(2 ** attempt * 1000, 30_000);
    await new Promise(r => setTimeout(r, backoff + Math.random() * 1000));
  }
}
```

Our official SDKs (Node, Python, Go, CLI) apply this policy automatically. You only need to bake it in yourself if you're calling the raw HTTP API.

## Debugging a failed request

Every response carries `request_id` in the body. If you email <hello@chocodata.com> with that ID, we can pull the full internal trace - upstream status, attempts, latency per attempt, extractor output. Keep it in your own logs for at least 7 days so we don't both have to guess later.

## Related

- [Core concepts](/docs/core-concepts) - the response and error envelope
- [Authentication](/docs/guides/authentication) - key format + rotation
- [Rate limits & concurrency](/docs/guides/rate-limits) - per-plan ceilings
- [Billing policy](/docs/guides/billing) - only-2xx rule in detail
