> ## Documentation Index
> Fetch the complete documentation index at: https://vpn-docs.wxapros.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> HTTP status codes, retry strategy, and the standard error envelope.

## Error envelope

Every error response uses the same JSON shape:

```json theme={null}
{
  "error": {
    "code": "rate_limited",
    "message": "Sustained rate limit exceeded — retry after 12 seconds",
    "details": {
      "retry_after_seconds": 12,
      "tier": "pro",
      "limit_rpm_60s": 3000
    }
  }
}
```

`code` is a stable machine-readable identifier; `message` is human-readable
and may change. Always switch on `code`, not `message`.

## Status codes

| Status | Code               | Meaning                                         | Retry?                    |
| ------ | ------------------ | ----------------------------------------------- | ------------------------- |
| `400`  | `bad_request`      | Malformed request body or invalid IP format     | No — fix the request      |
| `401`  | `unauthorized`     | API key / gateway auth missing or invalid       | No — fix credentials      |
| `403`  | `forbidden`        | Key tier does not permit this endpoint or field | No — upgrade tier         |
| `404`  | `not_found`        | Resource doesn't exist (rare)                   | No                        |
| `422`  | `validation_error` | Pydantic validation failed                      | No — fix payload          |
| `429`  | `rate_limited`     | Too many requests                               | Yes — see headers         |
| `500`  | `internal_error`   | Unexpected server error                         | Yes — exponential backoff |
| `503`  | `unhealthy`        | Service degraded                                | Yes — exponential backoff |

On the IP endpoints, a `400 bad_request` carries a more specific `code` in the
envelope — one of `invalid_ip_reserved` (RFC-reserved / non-global address),
`invalid_ip_integer_form` (bare integer not accepted), or `invalid_ip_format`
(unparseable). Switch on these subcodes when you need to distinguish why an IP
was rejected.

## Rate-limit headers

Every response carries the current rate-limit state:

```
X-RateLimit-Limit: 3000
X-RateLimit-Remaining: 2997
X-RateLimit-Window: 60
```

A `429` response adds two headers telling you when to retry:

```
Retry-After: 12                 # seconds to wait
X-RateLimit-Reset: 1714339200   # Unix timestamp when the next slot frees
```

`X-RateLimit-Limit` is your request ceiling, expressed as requests per
60-second window (not per second). `X-RateLimit-Remaining` is how many requests
remain in the current window. `X-RateLimit-Window` is the window length in
seconds. On a `429`, `Retry-After` is the recommended wait in seconds and
`X-RateLimit-Reset` is the Unix timestamp when the next slot frees (≈
`now + Retry-After`). Because this is a sliding-window limiter, the exact reset
instant is only defined at denial time, so `X-RateLimit-Reset` appears on `429`
responses only — prefer `Retry-After` for backoff.

## Retry strategy

For transient errors (`429`, `500`, `503`):

```python theme={null}
import httpx, time, random

def lookup_with_retry(ip, key, max_attempts=5):
    for attempt in range(max_attempts):
        resp = httpx.get(
            f"https://wxaintel.wxapros.com/api/v1/vpn/ip/{ip}",
            headers={"X-API-Key": key},
            timeout=10,
        )
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 1))
            time.sleep(wait)
            continue
        if resp.status_code in (500, 503):
            backoff = 2 ** attempt + random.random()
            time.sleep(backoff)
            continue
        # 4xx other than 429 → don't retry
        resp.raise_for_status()
    raise RuntimeError(f"Exhausted {max_attempts} retries for {ip}")
```

**Don't retry 4xx errors other than 429.** They indicate a problem with
your request, and retrying just wastes quota.

## Circuit breaking

For high-volume integrations, wrap calls in a circuit breaker so a brief
outage doesn't cascade into your application:

```python theme={null}
from circuitbreaker import circuit

@circuit(failure_threshold=5, recovery_timeout=30)
def lookup(ip, key):
    # ...
```

We aim for 99.5 % (Pro) / 99.9 % (Business) / 99.95 % (Enterprise)
availability, but caller-side circuit breakers are still recommended.
