---
title: Errors
description: Every HTTP status Lovely Router returns, the error body for each API format, and when and how to retry.
---

Lovely Router uses standard HTTP status codes. `2xx` means success, `4xx` means the request needs a change (or your account needs credit), and `5xx` or `429` means a temporary problem worth retrying. The body says why, in the format of the API you called, so the official SDKs raise their usual exceptions.

## Status codes

| Status | Meaning | Common causes | Retry? |
| --- | --- | --- | --- |
| `400` | Bad request | Body isn't valid JSON. The model rejected a parameter, such as `max_tokens` above its limit. Tool input is missing a required field. Unknown `x-lovely-group`. | No. Fix the request. |
| `401` | Unauthenticated | Key missing, mistyped, revoked or expired | No. Check the key. |
| `402` | Payment required | Balance at $0. The key reached its spend limit. Your balance can't cover a run's maximum cost. | After you add credit or raise the limit |
| `403` | Forbidden | Model not on the key's allow-list | No |
| `404` | Not found | Unknown model id, model not offered in this API format, unknown endpoint, tool or run | No |
| `405` | Method not allowed | Wrong HTTP method on a browser vault or files endpoint | No |
| `409` | Conflict | Reply to a browser run that isn't waiting for that reply, or has finished. File storage limit reached. | No. Check the run's state. |
| `413` | Payload too large | Prompt too large for the model. Tool input over 50 KB. File over 10 MB. | No. Make it smaller. |
| `422` | Unprocessable | The model rejected the content of the request | No |
| `429` | Too many requests | Key rate or concurrency limit, model capacity busy, 10 runs already in progress, planner limit | Yes, after `retry-after` |
| `500` | Internal error | Unexpected error in Lovely Router | Yes, with backoff |
| `502` | Bad gateway | The model failed on every channel tried. A tool couldn't start (you're not charged). | Yes, with backoff |
| `503` | Service unavailable | A data tool or the browser agent is temporarily unavailable | Yes, with backoff |

Treat any other `5xx` like `502`. Errors from requests refused before reaching a model (`400` for invalid JSON, `401`, `402`, `403`, `404` for an unknown model, and `429` from a key limit) are never charged.

## Error bodies for model endpoints

Model endpoints return errors in the format of the API you called. Each tab below shows the same `401`.

```json title="Anthropic"
{"type": "error", "error": {"type": "authentication_error", "message": "Invalid or revoked API key"}}
```

```json title="OpenAI"
{"error": {"message": "Invalid or revoked API key", "type": "authentication_error", "code": "authentication_error"}}
```

```json title="Gemini"
{"error": {"code": 401, "message": "Invalid or revoked API key", "status": "UNAUTHENTICATED"}}
```

The error `type` (Anthropic and OpenAI) or `status` (Gemini) for each code:

| Status | Anthropic `error.type` | OpenAI `error.type` and `error.code` | Gemini `error.status` |
| --- | --- | --- | --- |
| `400` | `invalid_request_error` | `invalid_request_error` | `INVALID_ARGUMENT` |
| `401` | `authentication_error` | `authentication_error` | `UNAUTHENTICATED` |
| `402` | `billing_error` | `insufficient_quota` | `RESOURCE_EXHAUSTED` |
| `403` | `permission_error` | `permission_denied` | `PERMISSION_DENIED` |
| `404` | `not_found_error` | `not_found_error`, or `not_found` for an unknown endpoint | `NOT_FOUND` |
| `413`, `422` | `invalid_request_error` | `invalid_request_error` | `INVALID_ARGUMENT` |
| `429` | `rate_limit_error` | `rate_limit_exceeded` | `RESOURCE_EXHAUSTED` |
| `500` | `api_error` | `api_error` | `INTERNAL` |
| `502`, `503` | `api_error` | `api_error` | `UNAVAILABLE` |
| `504` | `api_error` | `api_error` | `DEADLINE_EXCEEDED` |

In Gemini bodies, `error.code` always equals the HTTP status. Branch on the status code rather than the `status` string. Errors raised before the request is read, such as an unknown endpoint or an unexpected gateway error, also use the format of the endpoint you called.

### Messages you may see

| Status | Message |
| --- | --- |
| `400` | `Body must be JSON` |
| `400` | `Unknown group 'xyz'` |
| `400`, `413`, `422` | The model's own reason, for example `prompt is too long` |
| `401` | `Missing API key`, `Invalid or revoked API key`, `This API key has expired` |
| `402` | `Insufficient balance. Top up at the dashboard.` |
| `402` | `This API key has reached its spend limit.` |
| `403` | `This API key is not allowed to use 'gpt-6.1-sol'.` |
| `404` | `Model 'gpt-9' is not available. See GET /v1/models.` |
| `404` | `Unknown endpoint /v1/embeddings` |
| `429` | `This API key has reached its rate or concurrency limit.` |
| `429` | `All upstream capacity for this model is busy. Retry shortly.` |
| `502` | `The model provider returned an error. Please retry.` |

Every error response includes an `x-lovely-request-id` header, including requests refused before they reach a model (bad key, empty balance, unknown model). Quote it when you [contact support](/app/support).

## Error bodies for tools and the browser agent

`/v1/tools` and `/v1/browser` return one format for every error: a machine-readable `type` and a message written for people.

```json
{"error": {"type": "insufficient_quota", "message": "Insufficient balance: this run can cost up to $0.3000 (100 results). Lower max_results or top up."}}
```

| Status | `error.type` | Examples |
| --- | --- | --- |
| `400` | `invalid_request_error` | `Missing required input: query. See GET /v1/tools/{id}.`, `"start_url" must be an http(s) URL` |
| `401` | `authentication_error` | `Missing, invalid or revoked API key`, `This API key has expired` |
| `402` | `insufficient_quota` | Balance can't cover the run's maximum cost, or the key's spend limit would be exceeded |
| `404` | `not_found` | `Unknown tool 'x'. Find one with POST /v1/tools/discover.`, `No such run` |
| `405` | `invalid_request_error` | Wrong method on a vault or files endpoint |
| `409` | `invalid_request_error` | `This run is not waiting for a reply`, `This run has finished` |
| `413` | `invalid_request_error` | `Input too large`, `Files are limited to 10 MB` |
| `429` | `rate_limit_exceeded` | `At most 10 tool runs may be running at once.` |
| `500` | `api_error` | `Internal error` |
| `502`, `503` | `tool_unavailable` | `The tool is temporarily unavailable; you were not charged. Try again shortly.` |

A run that starts and then fails is not an HTTP error. The run object comes back with `status` set to `failed`, `timed_out` or `stopped` and an `error` message, and you pay only for results delivered. See [Run a tool](/docs/tools/run).

## Errors during a stream

Once a stream has started, the status is already `200`, so errors arrive as an event in the stream instead. Failures before the first token are retried on another channel, so you normally see a stream either succeed or fail with a plain HTTP error.

If a model stops sending data for 3 minutes mid-stream, Lovely Router ends the stream with an error event in the stream's format:

```sse title="Anthropic"
event: error
data: {"type": "error", "error": {"type": "api_error", "message": "Upstream stopped responding mid-stream"}}
```

```sse title="OpenAI"
data: {"error": {"message": "Upstream stopped responding mid-stream", "type": "api_error", "code": "upstream_stall"}}
```

```sse title="Gemini"
data: {"error": {"code": 504, "message": "Upstream stopped responding mid-stream", "status": "DEADLINE_EXCEEDED"}}
```

An error the channel reports inside a running stream is replaced by a generic event in the same format (for the Responses API, an `error` event like `{"type": "error", "code": "server_error", "message": "…"}`), so its own wording never reaches you. A busy or overloaded channel becomes Anthropic `overloaded_error`, OpenAI `rate_limit_exceeded` or Gemini `RESOURCE_EXHAUSTED` with the message `Upstream capacity for this model is busy. Retry shortly.`; anything else becomes `api_error` (Gemini `UNAVAILABLE`) with `The model provider returned an error. Please retry.` Retry the request from the start; output already streamed is charged.

## Retry failed requests

Lovely Router already retries failed model requests on other channels before returning an error. Retry on your side too, for the cases it can't absorb:

- **Retry** `429`, `500`, `502`, `503`, `504` and network errors.
- **Wait** for the `retry-after` header when it's present. Otherwise back off exponentially: 1, 2, 4, 8 seconds and so on, capped at 30 seconds, plus up to a second of random jitter so clients don't retry in lockstep.
- **Stop** after about five attempts and surface the error.
- **Don't retry** `400`, `401`, `403`, `404`, `409`, `413` or `422` without changing the request. Retry `402` only after adding credit.
- **Don't blindly retry run starts.** If starting a tool or browser run times out, check `GET /v1/tools/runs` before starting another. See [Retries and idempotency](/docs/api#retries-and-idempotency).

The official OpenAI and Anthropic SDKs already retry `429` and `5xx` twice with backoff. Pass `max_retries` (Python) or `maxRetries` (Node.js) to the client to change that. For plain HTTP calls, use a helper like this:

```python title="Python"
import os
import random
import time

import requests

RETRYABLE = {429, 500, 502, 503, 504}


def backoff(attempt, retry_after=None):
    if retry_after:
        return float(retry_after)
    return min(30, 2 ** (attempt - 1)) + random.random()


def post_with_retry(url, payload, max_attempts=5):
    headers = {"Authorization": f"Bearer {os.environ['LOVELY_API_KEY']}"}
    for attempt in range(1, max_attempts + 1):
        try:
            r = requests.post(url, json=payload, headers=headers, timeout=300)
        except (requests.ConnectionError, requests.Timeout):
            if attempt == max_attempts:
                raise
            time.sleep(backoff(attempt))
            continue
        if r.status_code in RETRYABLE and attempt < max_attempts:
            time.sleep(backoff(attempt, r.headers.get("retry-after")))
            continue
        if not r.ok:  # other 4xx, or still failing after the last attempt
            raise RuntimeError(f"{r.status_code}: {r.text[:500]}")
        return r.json()


reply = post_with_retry(
    "https://lovelyrouter.com/v1/chat/completions",
    {"model": "gpt-6.1-sol", "messages": [{"role": "user", "content": "Hello"}]},
)
print(reply["choices"][0]["message"]["content"])
```

```js title="Node.js"
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt, retryAfter) =>
  retryAfter ? Number(retryAfter) * 1000 : Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 1000;

async function postWithRetry(url, body, maxAttempts = 5) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.LOVELY_API_KEY}`,
          'content-type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(300_000),
      });
    } catch (err) {
      if (attempt >= maxAttempts) throw err;
      await sleep(backoff(attempt));
      continue;
    }
    if (RETRYABLE.has(res.status) && attempt < maxAttempts) {
      await sleep(backoff(attempt, res.headers.get('retry-after')));
      continue;
    }
    const text = await res.text();
    // Other 4xx, or still failing after the last attempt
    if (!res.ok) throw new Error(`${res.status}: ${text.slice(0, 500)}`);
    return JSON.parse(text);
  }
}

const reply = await postWithRetry('https://lovelyrouter.com/v1/chat/completions', {
  model: 'gpt-6.1-sol',
  messages: [{ role: 'user', content: 'Hello' }],
});
console.log(reply.choices[0].message.content);
```

Every error format, Anthropic, OpenAI, Gemini and Lovely Router's own, has an `error.message` written for people. Log it with the status code and the `x-lovely-request-id` header.
