> Searlo API Docs — Error Handling
> Source: https://searlo.tech/docs#errors
> Full docs: https://searlo.tech/docs/full.md

# Error Handling

Every error answers JSON with `success: false` and a human-readable `message`, and almost every one adds a stable machine-readable name for the failure. Branch on that name and the status, not on `message`, which may be reworded. The name is in `code`; on a few errors, marked † below, it is in `errorCode` instead, so read `code` and fall back to `errorCode`.

### Error Response Format

```json
{
  "success": false,
  "code": "AT_CAPACITY",
  "message": "Google SERP is at capacity. Retry in 60s — immediate retries will be refused.",
  "retryable": true,
  "retryAfterSec": 60
}
```

- `retryable` says whether the same request can succeed later. When it is `true`, wait `Retry-After` (header) or `retryAfterSec` (body) before retrying.
- Validation errors add an `errors` array of `{ field, message }`:

```json
{
  "success": false,
  "code": "VALIDATION_ERROR",
  "message": "Query validation failed: \"q\" is required",
  "errors": [{ "field": "q", "message": "\"q\" is required" }]
}
```

- Insufficient-credit errors (`402`) add `details`:

```json
{
  "success": false,
  "error": "Insufficient credits",
  "code": "INSUFFICIENT_CREDITS",
  "details": { "required": 5, "available": 2, "shortfall": 3 },
  "message": "This request requires 5 credit(s), but you only have 2 credit(s) available. Please purchase more credits to continue.",
  "topUpUrl": "https://dashboard.searlo.tech/credits?tab=buy&source=api_402"
}
```

### Codes

Every status and code the API answers, in one table. **A call that answers with one of them is never charged**: it releases the credits it held, and the `429`s for rate limits and searches in flight are refused before any credit is reserved. A `200` that finds nothing (an empty list) is not charged either. The one exception is a failed row inside a `200` `POST /rankings/batch`, which is billed with the batch (see Refunds). † marks a name carried in `errorCode`.

| Status | `code` | Meaning | Retry |
|--------|----------|---------|-------|
| `400` | `VALIDATION_ERROR` | A parameter is missing, malformed or out of range (`errors` lists which). Also a URL that cannot be fetched as written, and an image page past Google's last | Fix the request |
| `400` | `INVALID_JSON` | The body is not valid JSON | Fix the request |
| `400` | `INVALID_IDEMPOTENCY_KEY` | The idempotency key is longer than 255 characters | Fix the request |
| `400` | `CITY_LOCATION_UNAVAILABLE` | `location` or `uule` names a city or a state; only countries are applied ([Location](#full-serp)) | No |
| `400` | `UNKNOWN_LOCATION` | `location` is not a country we recognise; `suggestions` lists close matches | Fix the request |
| `400` | `INVALID_DOMAIN` | A rank check's `domain` cannot be parsed | Fix the request |
| `400` | `PAGE_OUT_OF_RANGE` † | Web, site and advanced search: the page starts past result 101. `maxPage` is the last page that can be served | No |
| `400` | `DATE_FILTER_UNAVAILABLE` † | A date filter (`dateRestrict`, `dateRange`) combined with search operators such as `site:` or `filetype:` | Remove the filter or the operators |
| `400` | `INVALID_REQUEST` † | Web data: the fetcher did not accept the URL; `message` says why | Fix the request |
| `400` / `404` | `PLACE_NOT_FOUND` † | Place details: the `placeId` names no business or `q` matched nothing (`400`), or Google does not know the id (`404`) | No |
| `401` | `UNAUTHORIZED` | A missing, invalid or expired API key, or a removed account | Fix the key |
| `402` | `INSUFFICIENT_CREDITS` | Not enough credits for this call. `details` gives `required`, `available` and `shortfall`, and `topUpUrl` links to buying more. On `POST /extract`, on crawl and batch-scrape starts and on async tasks the name is in `errorCode` † | After a top-up |
| `403` | `FORBIDDEN` | The account is suspended | No |
| `403` | `FEATURE_NOT_AVAILABLE` | The endpoint is switched off on this deployment; `retryable: false` | No |
| `403` | `API_KEY_SCOPE_FORBIDDEN` | An API key used on an account-management route (API keys, data export, account deletion, payments, billing); those need a dashboard login. `GET /user/profile` stays open to keys | No |
| `404` | `NOT_FOUND` | Unknown route or resource | No |
| `409` | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | A request with this idempotency key is still running | Shortly |
| `413` | `PAYLOAD_TOO_LARGE` | The request body is too large | No |
| `422` | `QUERY_NOT_SERVED` | Full SERP and SERP HTML: a query shape these endpoints do not serve (search operators, e-mail lookups, long `OR` chains) | No |
| `422` | `COUNTRY_UNAVAILABLE` | Full SERP and SERP HTML: this `gl` cannot be served right now | Later |
| `422` | `IDEMPOTENCY_KEY_REUSED` | This idempotency key was already used for a different request | With a new key |
| `422` | `TARGET_REFUSED` † | Web data: the site refused the fetch; `targetStatus` is its own status | Only if `retryable` |
| `422` | `TARGET_NOT_ALLOWED` | Web data: a private or reserved network address | No |
| `422` | `TARGET_UNREACHABLE` † | Web data: no attempt could fetch the site (its secure-connection handshake is refused, it redirects in a loop, or the page is too large); `targetError` says which | No |
| `429` | `4201` | The account's [rate limit](#rate-limiting) | After `Retry-After` |
| `429` | `4202` | The API key's own rate limit | After `Retry-After` |
| `429` | `4203` | The account has too many searches in flight (`errorCode` `CONCURRENCY_LIMIT`; see [Rate Limiting](#rate-limiting)) | After `Retry-After` (1 second) |
| `429` | `AT_CAPACITY` | Full SERP, rank checks, AI answers, shopping: at capacity | After `Retry-After` |
| `429` | `BUSY` | SERP HTML: at capacity | After `Retry-After` |
| `429` | `VERIFIER_BUSY` | E-mail verifier: verification is paced; `Retry-After` is 30 seconds by default | After `Retry-After` |
| `500` | `INTERNAL_ERROR` | Something failed on our side | Yes, with backoff |
| `503` | `UPSTREAM_UNAVAILABLE` | The upstream (Google, an AI engine, or our own backend) did not give a usable answer; `retryable: true` | Yes, after `Retry-After` |
| `503` | `UPSTREAM_RATE_LIMITED` | Search is backing off from its upstream; `retryAfter` is the wait | Yes, after `Retry-After` |
| `503` | `AT_CAPACITY` | Web, image, news and the other search endpoints at capacity (a `429` on SERP and AI answers) | Yes, after `Retry-After` |
| `503` | `WARMING` | The search endpoint is starting up | Yes, after `Retry-After` |
| `503` | `UPSTREAM_TIMEOUT` | Our backend did not answer in time; `retryable: true` | Yes, after `Retry-After` |
| `503` | `CREDIT_SYSTEM_UNAVAILABLE` | Credit checks are briefly unavailable, so the call was refused before it ran; `retryable: true` | Yes, after `Retry-After` |
| `503` | `REVIEWS_WITHHELD` | Reviews: Google sent the place without its reviews; `reviewCount` is what it reports; `retryable: true` | Yes |
| `503` | `SERVICE_UNAVAILABLE` | The service cannot take the call right now; see `retryable` | If `retryable` |
| `503` | `UPSTREAM_FAILED` | A search failed and a retry will not help; `retryable: false` | No |

The rate-limit `429`s carry a numeric `code` (`4201`, `4202`, `4203`); every other `code` is a string. Errors are JSON on every status, a `5xx` included: we answer `503` rather than `502` or `504`, which the network in front of us would replace with its own HTML page. Every retryable `503` carries `Retry-After`.

### Idempotency keys

Send `X-Idempotency-Key` (or `Idempotency-Key`), up to 255 characters, to make a retry of a paid call safe. Within an hour of the first use:

- the same key with the same request returns the stored response, marked `Idempotent-Replayed: true`, without running or charging again;
- the same key with a different request is `422` `IDEMPOTENCY_KEY_REUSED`, not charged;
- the same key while the first request is still running is `409` `IDEMPOTENCY_REQUEST_IN_PROGRESS`: retry shortly (`Retry-After: 2`).

A request that failed is not kept, so you can retry it with the same key. A key longer than 255 characters is `400` `INVALID_IDEMPOTENCY_KEY`. Async tasks take their own `idempotencyKey` in the body ([Async Tasks](#async-tasks)).

### Refunds

A call that fails is not charged: any response with status `400` or above, or with `success: false`, releases the credits it reserved. An answer with no results (an empty `organic`, `news`, `videos`, `images`, `places`, `reviews` or `shopping` list) is not charged either, and the body's `credits` always equals the `X-Credits-Deducted` header. In `POST /search/batch` and `POST /serp/batch` a failed or empty row costs nothing; a row inside a `200` `POST /rankings/batch` is billed like the rest of that batch (read each row's `success` and `retryable`). A failed [async task](#async-tasks) is refunded.

### Retrying

Retry only what says it can succeed later: `429`s, and `5xx` responses with `retryable: true`. Wait `Retry-After` when it is present, and back off otherwise (for example 2, 5, then 10 seconds). Because failures are refunded, a retry never bills twice.

## Code Examples

### JavaScript

```javascript
// Retries what says it can succeed later, waiting Retry-After when given.
// Failed calls are refunded, so a retry never bills twice.
async function searlo(path, params = {}, { retries = 3 } = {}) {
  const url = new URL(`https://api.searlo.tech/api/v1${path}`);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { "x-api-key": process.env.SEARLO_API_KEY } });
    const body = await res.json();
    if (res.ok && body.success !== false) return body;

    const retryable = res.status === 429 || (res.status >= 500 && body.retryable !== false);
    if (!retryable || attempt >= retries) {
      const err = new Error(body.message || `HTTP ${res.status}`);
      err.status = res.status;
      err.code = body.code; // e.g. CITY_LOCATION_UNAVAILABLE, INSUFFICIENT_CREDITS
      throw err;
    }
    const wait = Number(res.headers.get("retry-after")) || [2, 5, 10][attempt] || 10;
    await new Promise(r => setTimeout(r, wait * 1000));
  }
}
```

### Python

```python
import os
import time
import requests

class SearloError(Exception):
    def __init__(self, status, code, message):
        super().__init__(message)
        self.status, self.code = status, code

def searlo(path, params=None, retries=3):
    url = f"https://api.searlo.tech/api/v1{path}"
    headers = {"x-api-key": os.environ["SEARLO_API_KEY"]}
    for attempt in range(retries + 1):
        r = requests.get(url, params=params, headers=headers, timeout=120)
        body = r.json()
        if r.ok and body.get("success") is not False:
            return body
        retryable = r.status_code == 429 or (r.status_code >= 500 and body.get("retryable") is not False)
        if not retryable or attempt == retries:
            raise SearloError(r.status_code, body.get("code"), body.get("message", "request failed"))
        time.sleep(float(r.headers.get("Retry-After") or [2, 5, 10][min(attempt, 2)]))
```


---
*From https://searlo.tech/docs — see full docs at https://searlo.tech/docs/full.md*