# Errors

> One error shape for every endpoint, a closed set of types, and which of them are worth retrying. What gets refunded when a call fails.

Canonical page: https://www.refolk.ai/developers/errors



Every failure on every endpoint has the same shape, and the type is from a
closed set. That is deliberate: a client writing a retry policy needs to know
which failures are worth retrying, and free text cannot answer that.

## The shape

```json
{
  "error": {
    "type": "insufficient_credits",
    "message": "This search reserves 10 credits and your balance is 3. Top up at /pricing.",
    "balance": 3
  }
}
```

Branch on `type`. Print `message`: it is written for the person reading their
own terminal, and it says what to change rather than apologising.

## The types

| Type | Status | Retry? |
| --- | --- | --- |
| `unauthorized` | 401 | No. No key, a malformed one, or one that has been revoked. |
| `invalid_request` | 400 | No. The body or a field is wrong, and the message names which. |
| `insufficient_credits` | 402 | After topping up. Carries `balance`. |
| `rate_limited` | 429 | After `retryAfter` seconds. |
| `upstream_error` | 502 | Once. A source I read failed; any charge is refunded. |
| `server_error` | 500 | With backoff. My fault. |
| `forbidden` | 403 | No. A valid key, but not for this. |

## Retrying without making it worse

**TypeScript**

```typescript
async function withRetry<T>(call: () => Promise<Response>): Promise<T> {
  const retryable = ["server_error", "upstream_error", "rate_limited"];
  for (let attempt = 0; ; attempt++) {
    const res = await call();
    if (res.ok) return res.json();

    const { error } = await res.json();
    if (!retryable.includes(error.type) || attempt >= 3) {
      throw new Error(`${error.type}: ${error.message}`);
    }
    const wait = error.retryAfter ?? 2 ** attempt;
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}
```

**Python**

```python
import time

RETRYABLE = {"server_error", "upstream_error", "rate_limited"}

def with_retry(call, attempts=4):
    for attempt in range(attempts):
        res = call()
        if res.ok:
            return res.json()
        error = res.json()["error"]
        if error["type"] not in RETRYABLE or attempt == attempts - 1:
            raise RuntimeError(f"{error['type']}: {error['message']}")
        time.sleep(error.get("retryAfter", 2 ** attempt))
```

Retry `server_error` and `upstream_error` with exponential backoff. Retry
`rate_limited` after the time it asked for, and not before: the limit is a
token bucket, so an early retry spends the budget it was waiting for. Never
retry `unauthorized` or `invalid_request` in a loop, because nothing about
either will change on its own.

## What a broken run tells you, and what it does not

A failed search reports one sentence and no internals. That is on purpose: the
underlying message names the model stack and the services behind it, which are
not yours to debug, and an upstream "API key is invalid" read literally sends
somebody off to revoke and re-mint a key of their own that was working
perfectly. The real message goes to the error tracker, where the person who can
act on it will see it.

> **A source failing is not your bug**
>
> An `upstream_error` means the request was well formed and something I read
> was not available. That is why it is a 502 rather than a 400, and why any
> charge is refunded rather than kept.

## Errors inside a stream

Once a streamed search has started, the HTTP status is already `200` and there
is no way to take it back. A failure after that point arrives as an
`{ "type": "error", "message": "..." }` event on the stream.

Everything that can refuse a search - no credits, too many at once, a malformed
body - is decided before the first byte is written, so those stay ordinary
status codes.



---

## More of the API documentation

- [Quickstart](https://www.refolk.ai/developers/quickstart.md): Create a key, run your first search, and read what comes back. A working request in under two minutes, in curl, TypeScript, and Python.
- [Authentication](https://www.refolk.ai/developers/authentication.md): How API keys work: creating one, sending it, rotating it, and what happens when one leaks. Keys are shown once and hashed at rest.
- [Credits and limits](https://www.refolk.ai/developers/credits-and-limits.md): What each call costs, how a search is reserved and reconciled, what the rate limits are, and how to check a balance before a long run.
- [Playground](https://www.refolk.ai/developers/playground.md): Run any endpoint from the browser, signed in, on your own credits. Prefilled examples, the real response, and the equivalent curl for your own code.
- [Search in plain English](https://www.refolk.ai/developers/agentic-search.md): POST /v1/search. One sentence in, a ranked shortlist out, with the evidence behind every pick. Blocking or streamed as server-sent events.
- [Search with filters](https://www.refolk.ai/developers/structured-search.md): POST /v1/people/search. An exact filter set in, a deterministic page of people out, one credit a call. Every field, every allowed value, and how to paginate.
- [MCP server](https://www.refolk.ai/developers/mcp.md): Give Claude, Cursor, VS Code, or any MCP client the ability to search for people mid-conversation. One URL, one header, four tools.
- [OpenAPI document](https://www.refolk.ai/api/v1/openapi.json): the machine-readable specification.

Authenticate with `Authorization: Bearer rfk_live_...`. Keys are created at https://www.refolk.ai/hire/api.