> For the complete documentation index, see [llms.txt](https://docs.pullbay.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.pullbay.com/documentation/concepts/errors-and-retries.md).

# Errors and Retries

Understand Pullbay API error codes, HTTP status codes, and how to implement robust retry logic.

### Error Response Format

All Pullbay API errors follow a consistent JSON envelope:

```json
{
  "requestId": "3b8dcb68-1f8c-4a7b-b9e7-63a7b9986f24",
  "status": 400,
  "message": "Validation failed",
  "success": false,
  "error": { "code": "VALIDATION_ERROR" }
}
```

| Field        | Type    | Description                                                              |
| ------------ | ------- | ------------------------------------------------------------------------ |
| `requestId`  | string  | Unique identifier for this request. Save this for debugging and support. |
| `status`     | integer | HTTP status code mirrored in the body                                    |
| `message`    | string  | Human-readable description of what went wrong                            |
| `success`    | boolean | Always `false` for errors                                                |
| `error.code` | string  | Machine-readable error code for programmatic handling                    |

The `requestId` is also returned as the `X-Request-Id` response header.

**Always include `requestId` when contacting Pullbay support** — it allows the support team to instantly locate your request in the logs.

***

### HTTP Status Codes Reference

| Status  | Name                  | Retryable? | Meaning                        | What to Do                           |
| ------- | --------------------- | ---------- | ------------------------------ | ------------------------------------ |
| **200** | OK                    | N/A        | Request successful             | Process the response data            |
| **400** | Bad Request           | No         | Invalid parameters or format   | Fix your request and resubmit        |
| **401** | Unauthorized          | No         | Invalid or missing API key     | Verify your API key                  |
| **402** | Payment Required      | No         | Insufficient credits           | Add credits in your dashboard        |
| **403** | Forbidden             | No         | API key lacks permission       | Use a key with the right permissions |
| **404** | Not Found             | No         | Endpoint or resource not found | Check the endpoint URL               |
| **429** | Too Many Requests     | Yes        | Rate limit exceeded            | Back off and wait for reset          |
| **500** | Internal Server Error | Yes        | Temporary server error         | Wait and retry with backoff          |
| **502** | Bad Gateway           | Yes        | Upstream service unavailable   | Wait and retry                       |
| **503** | Service Unavailable   | Yes        | Maintenance or overload        | Wait longer before retrying          |

***

### Non-Retryable Errors (4xx except 429)

Do **not** retry requests that return `400`, `401`, `402`, `403`, or `404`. These indicate problems with your request that won't resolve by waiting:

```python
NON_RETRYABLE_CODES = {400, 401, 402, 403, 404}

if response.status_code in NON_RETRYABLE_CODES:
    body = response.json()
    print(f"Non-retryable error: {body['error']['code']}")
    print(f"Message: {body['message']}")
    print(f"Request ID: {body['requestId']}")
    # Fix the underlying issue; don't retry
```

### Retryable Errors (429 and 5xx)

These errors are usually temporary and will likely succeed if retried with backoff:

```python
RETRYABLE_CODES = {429, 500, 502, 503}

if response.status_code in RETRYABLE_CODES:
    print(f"Transient error {response.status_code} — implement backoff and retry")
```

***

### Common Error Codes

#### Authentication Errors

| Code              | HTTP Status | Description                           | Solution                              |
| ----------------- | ----------- | ------------------------------------- | ------------------------------------- |
| `INVALID_API_KEY` | 401         | API key is malformed or doesn't exist | Check your API key format and value   |
| `MISSING_API_KEY` | 401         | Authorization header is missing       | Include `Authorization: Bearer <key>` |

**Correct format:**

```python
headers = {
    "Authorization": "Bearer your_api_key_here"
}
# NOT: "Authorization": your_api_key_here"   (missing "Bearer ")
# NOT: "x-api-key: your_api_key_here"       (also accepted, but Bearer preferred)
```

#### Request Parameter Errors

| Code               | HTTP Status | Description                               | Solution                              |
| ------------------ | ----------- | ----------------------------------------- | ------------------------------------- |
| `VALIDATION_ERROR` | 400         | Parameter has invalid value or wrong type | Check parameter types and constraints |

**Example:**

```json
{
  "requestId": "3b8dcb68-...",
  "status": 400,
  "message": "Validation failed",
  "success": false,
  "error": { "code": "VALIDATION_ERROR" }
}
```

**Fix — include all required parameters with correct types:**

```python
# Missing required parameter
params = {"maxItems": 50}  # Missing 'location'!

# Correct
params = {
    "location": "Seattle, WA",  # Required
    "maxItems": 50              # Optional
}
```

#### Credit Errors

| Code                   | HTTP Status | Description                        | Solution                                  |
| ---------------------- | ----------- | ---------------------------------- | ----------------------------------------- |
| `INSUFFICIENT_CREDITS` | 402         | Account has run out of API credits | Add credits via dashboard or upgrade plan |

**Example:**

```json
{
  "requestId": "3b8dcb68-...",
  "status": 402,
  "message": "Insufficient credits",
  "success": false,
  "error": { "code": "INSUFFICIENT_CREDITS" }
}
```

**Do not retry a 402** — user action is required (top up credits):

```python
if response.status_code == 402:
    body = response.json()
    print(f"Out of credits. Request ID: {body['requestId']}")
    print("Visit your Pullbay dashboard to add credits.")
    # Do not retry
```

#### Rate Limit Errors

| Code                  | HTTP Status | Description                             | Solution                                  |
| --------------------- | ----------- | --------------------------------------- | ----------------------------------------- |
| `RATE_LIMIT_EXCEEDED` | 429         | Per-second or per-minute rate limit hit | Back off and retry after the reset window |

Rate limits are enforced per API key on both a per-second and a per-minute basis based on your plan. The response headers indicate the current limit state:

```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712973600
```

**Handle a 429:**

```python
if response.status_code == 429:
    reset_timestamp = int(response.headers.get("X-RateLimit-Reset", 0))
    wait_seconds = max(reset_timestamp - time.time(), 1)
    print(f"Rate limited. Waiting {wait_seconds:.1f}s...")
    time.sleep(wait_seconds + 1)
    # Then retry
```

#### Server Errors

| Code | HTTP Status | Description                     | Solution                       |
| ---- | ----------- | ------------------------------- | ------------------------------ |
| —    | 500         | Unexpected server error         | Retry with exponential backoff |
| —    | 502         | Upstream service unavailable    | Retry with backoff             |
| —    | 503         | Service maintenance or overload | Wait longer before retrying    |

***

### Retryable vs. Non-Retryable Summary

| Error Type           | Status Codes  | Retry?                    |
| -------------------- | ------------- | ------------------------- |
| Authentication       | 401           | No — fix your key         |
| Bad request          | 400           | No — fix your parameters  |
| Insufficient credits | 402           | No — add credits          |
| Forbidden            | 403           | No — check permissions    |
| Not found            | 404           | No — check endpoint URL   |
| Rate limited         | 429           | Yes — wait for reset      |
| Server errors        | 500, 502, 503 | Yes — exponential backoff |
| Network timeout      | —             | Yes — retry with backoff  |

***

### Production-Ready Python Retry Implementation

```python
import time
import random
import requests

API_KEY = "your_api_key_here"
BASE_URL = "https://api.pullbay.com"

RETRYABLE_CODES = {429, 500, 502, 503}
NON_RETRYABLE_CODES = {400, 401, 402, 403, 404}


class PullbayClient:
    def __init__(self, api_key, max_retries=5, base_delay=1):
        self.api_key = api_key
        self.max_retries = max_retries
        self.base_delay = base_delay

    def get(self, endpoint, params=None):
        headers = {
            "Authorization": f"Bearer {self.api_key}",
        }

        for attempt in range(self.max_retries + 1):
            try:
                response = requests.get(
                    f"{BASE_URL}{endpoint}",
                    params=params,
                    headers=headers,
                    timeout=30,
                )

                if response.status_code == 200:
                    return response.json()

                elif response.status_code in NON_RETRYABLE_CODES:
                    body = response.json()
                    raise Exception(
                        f"Non-retryable error {response.status_code}: "
                        f"{body['error']['code']} — {body['message']} "
                        f"(requestId: {body['requestId']})"
                    )

                elif response.status_code in RETRYABLE_CODES:
                    if attempt < self.max_retries:
                        wait = self._backoff(attempt, response)
                        print(
                            f"Retryable error {response.status_code} "
                            f"on attempt {attempt + 1}. "
                            f"Waiting {wait:.1f}s..."
                        )
                        time.sleep(wait)
                    else:
                        body = response.json()
                        raise Exception(
                            f"Max retries ({self.max_retries}) exceeded. "
                            f"Last error: {response.status_code} — {body['message']}"
                        )

            except requests.Timeout:
                if attempt < self.max_retries:
                    wait = self._backoff(attempt, None)
                    print(f"Timeout on attempt {attempt + 1}. Waiting {wait:.1f}s...")
                    time.sleep(wait)
                else:
                    raise Exception("Max retries exceeded: Timeout")

            except requests.ConnectionError as e:
                if attempt < self.max_retries:
                    wait = self._backoff(attempt, None)
                    print(f"Connection error on attempt {attempt + 1}: {e}. Waiting {wait:.1f}s...")
                    time.sleep(wait)
                else:
                    raise

        raise Exception("All retry attempts exhausted")

    def _backoff(self, attempt, response):
        if response and response.status_code == 429:
            reset = int(response.headers.get("X-RateLimit-Reset", 0))
            if reset > 0:
                return max(reset - time.time(), 1) + 1  # +1s buffer

        # Exponential backoff with 10% jitter
        delay = (2 ** attempt) * self.base_delay
        jitter = random.uniform(0, delay * 0.1)
        return delay + jitter


# Usage

client = PullbayClient(api_key=API_KEY, max_retries=5)

try:
    result = client.get(
        "/apartments/properties",
        params={"location": "Seattle, WA", "maxItems": 50}
    )
    data = result["data"]
    credits_charged = result["pricing"]["creditsCharged"]
    request_id = result["requestId"]
    print(f"Got {len(data)} results. Credits charged: {credits_charged}. Request ID: {request_id}")
except Exception as e:
    print(f"Failed: {e}")
```

***

### Debugging Tips

{% stepper %}
{% step %}

## Always log the `requestId`

Every response includes a `requestId`. Log it alongside every API call so you can reference it when contacting support.

```python
result = client.get("/apartments/properties", params={"location": "Seattle, WA"})
print(f"Request ID: {result['requestId']}")  # Save this in your logs
```

{% endstep %}

{% step %}

## Verify your parameters

Common mistakes:

* Missing required parameters
* Wrong types (e.g. string instead of integer)
* Values outside valid ranges (e.g. `maxItems` exceeding the endpoint limit)
* Mutually exclusive parameters used together (e.g. `page` and `maxItems`)
  {% endstep %}

{% step %}

## Check your API key

* Copy-paste directly from your Pullbay dashboard
* Include the `Bearer` prefix in the header
* Check that the key has not been revoked

```python
# Correct
headers = {"Authorization": "Bearer your_api_key_here"}

# Wrong — missing "Bearer "
headers = {"Authorization": "your_api_key_here"}
```

{% endstep %}

{% step %}

## Monitor rate limits

Check rate-limit headers on every response to proactively throttle:

```python
response = requests.get(f"{BASE_URL}/apartments/properties", headers=headers)
remaining = response.headers.get("X-RateLimit-Remaining")
limit = response.headers.get("X-RateLimit-Limit")
reset = response.headers.get("X-RateLimit-Reset")
print(f"Rate limit: {remaining}/{limit}, resets at {reset}")
```

{% endstep %}

{% step %}

## Check account credit balance

A `402` means your balance is zero. Add credits in the Pullbay dashboard and do not retry the same request programmatically.
{% endstep %}

{% step %}

## For 5xx errors, retry with backoff

`500`, `502`, and `503` are temporary. The recommended strategy is exponential backoff with jitter (see the implementation above).
{% endstep %}
{% endstepper %}

### Frequently Asked Questions

<details>

<summary>Are credits charged for failed requests?</summary>

* `429` (rate limit) — credits are **not** charged.
* `4xx` parameter errors — credits are **not** charged.
* `5xx` transient errors — credits are generally **not** charged, but check `pricing.creditsCharged` in the response to confirm.
* Successful requests (`200`) — credits **are** charged as shown in `pricing.creditsCharged`.

</details>

<details>

<summary>What does <code>VALIDATION_ERROR</code> mean?</summary>

It means one or more query parameters failed validation — missing a required field, a value outside the allowed range, or mutually exclusive parameters used together. Check the `message` field and compare your request against the endpoint's parameter table.

</details>

<details>

<summary>How do I handle rate limits gracefully?</summary>

Read the `X-RateLimit-Reset` header and wait until that Unix timestamp before retrying. The Python example above handles this automatically.

</details>

<details>

<summary>Can I increase retry attempts?</summary>

Yes — pass a higher `max_retries` to the client constructor. However, be aware that aggressive retrying during a sustained outage can look like a DDoS. For sustained failures, implement a circuit breaker that stops retrying after a threshold and alerts your on-call team.

</details>

<details>

<summary>What is <code>X-Request-Id</code>?</summary>

It is the same value as `requestId` in the response body, returned as a response header. Use whichever is more convenient for your logging layer.

</details>
