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

# Handle Errors and Retries

> Retry the right failures, respect rate limits, and decide on purpose whether an unchecked message goes out.

Enforcement sits in front of a send. When a check can't complete, your code has to choose between delivering unchecked content and holding it. This guide covers what can fail, which failures to retry, and how to make that choice deliberately.

## Four ways a check can go wrong

| What happened | How it shows up | Retry? |
| - | - | - |
| The request is wrong | `400`, `401`, `403`, `404`, `409`, `410`, `413`, `422` | No. Fix the request, key, or resource state. |
| You hit a limit | `429` with a `Retry-After` header | Yes, after the wait. See [Rate limits](#rate-limits-and-the-daily-quota). |
| The service had a problem | `500`, `502`, `503`, a network error, or a timeout | Yes, with backoff. |
| The job failed | HTTP `200` with `status: "failed"` and an `error` field | Treat as unavailable. Retry the whole submission once if you like. |

A fifth case is not an error. A `sync` request that doesn't fit the inline budget returns `mode: "async"` with a `poll.url`. Check `mode` and poll. See [Enforce Content](https://docs.zerodrift.com/api-reference/validate/validate-content.md).

The full list of status codes is on [Errors](https://docs.zerodrift.com/api-reference/errors.md).

## Rate limits and the daily quota

A `429` means the request was not processed, so it is safe to resend. There are two kinds, and they ask you to wait very different amounts of time:

| Error | `Retry-After` | What to do |
| - | - | - |
| `Rate limit exceeded` | `1` second | Wait and retry. Add jitter so parallel workers don't retry in lockstep. |
| `Daily quota exceeded` | `3600` seconds | Don't sleep for an hour in a request path. Stop retrying, apply your [fail policy](#fail-open-or-fail-closed), and alert. |

```text theme={null}
HTTP/2 429
retry-after: 1
content-type: application/json

{"error": "Rate limit exceeded", "message": "Too many requests — retry after 1 second, as indicated by the Retry-After header."}
```

Your limits depend on your plan. See [Rate limits](https://docs.zerodrift.com/api-reference/introduction.md#rate-limits), and contact [support@zerodrift.ai](mailto:support@zerodrift.ai) to raise them.

## Retry with backoff

Both samples follow the same rules:

* Retry `429`, any `5xx`, network errors, and timeouts. Never retry another `4xx`.
* Honor `Retry-After`. Otherwise back off 1, 2, 4, then 8 seconds, plus up to a second of jitter.
* Never wait more than 30 seconds. A longer `Retry-After` means a daily quota, so stop and apply your fail policy.
* A `4xx` raises `ClientError`, and the fail policy does not catch it. A wrong or revoked key must fail loudly, not quietly turn enforcement off.

```python Python theme={null}
import os
import random
import time

import requests

API = "https://api.zerodrift.ai"
HEADERS = {"x-api-key": os.environ["ZERODRIFT_API_KEY"]}
MAX_ATTEMPTS = 4
MAX_WAIT_S = 30
TERMINAL = {"completed", "failed"}


class ClientError(Exception):
    """A 4xx other than 429. Fix the request or key. Never fail open on this."""


class Unavailable(Exception):
    """Retries ran out, the API asked for a long wait, or the job failed."""


def request_with_retry(method: str, path: str, **kwargs) -> requests.Response:
    last_error = "no response"
    for attempt in range(MAX_ATTEMPTS):
        retry_after = None
        try:
            r = requests.request(
                method, f"{API}{path}", headers=HEADERS, timeout=30, **kwargs
            )
        except (requests.ConnectionError, requests.Timeout) as e:
            last_error = str(e)
        else:
            if r.ok:
                return r
            if r.status_code != 429 and r.status_code < 500:
                raise ClientError(f"HTTP {r.status_code}: {r.text[:200]}")
            last_error = f"HTTP {r.status_code}"
            retry_after = r.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2**attempt, 8)
        if delay > MAX_WAIT_S or attempt == MAX_ATTEMPTS - 1:
            break
        time.sleep(delay + random.random())
    raise Unavailable(last_error)


def check(content: str, fail_closed: bool = True) -> dict:
    try:
        r = request_with_retry(
            "POST",
            "/api/v3/content/validate",
            json={"content": content, "mode": "sync", "model_engine": "anchor_3_0"},
        )
        job = r.json()
        if job.get("mode") == "async":
            poll_url = job["poll"]["url"]
            deadline = time.monotonic() + 120
            while job["status"] not in TERMINAL:
                if time.monotonic() > deadline:
                    raise Unavailable(f"timeout polling {poll_url}")
                time.sleep(1)
                job = request_with_retry("GET", poll_url).json()
        if job["status"] == "failed":
            raise Unavailable(job.get("error", "enforcement failed"))
        return {"checked": True, "job": job}
    except Unavailable as e:
        print(f"ZeroDrift unchecked: {e}")  # log it, and alert on the count
        return {"checked": False, "deliver": not fail_closed, "reason": str(e)}


outcome = check("Our fund guarantees 20% returns with zero risk!", fail_closed=True)
if not outcome["checked"] and not outcome["deliver"]:
    print("Held: could not check this message.")
```

```typescript TypeScript theme={null}
const API = "https://api.zerodrift.ai";
const HEADERS = {
  "x-api-key": process.env.ZERODRIFT_API_KEY!,
  "Content-Type": "application/json",
};
const MAX_ATTEMPTS = 4;
const MAX_WAIT_MS = 30_000;
const TERMINAL = new Set(["completed", "failed"]);

/** A 4xx other than 429. Fix the request or key. Never fail open on this. */
class ClientError extends Error {}
/** Retries ran out, the API asked for a long wait, or the job failed. */
class Unavailable extends Error {}

const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

async function requestWithRetry(
  method: string,
  path: string,
  body?: unknown,
): Promise<Response> {
  let lastError = "no response";
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    let retryAfter: string | null = null;
    try {
      const r = await fetch(`${API}${path}`, {
        method,
        headers: HEADERS,
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      });
      if (r.ok) return r;
      if (r.status !== 429 && r.status < 500) {
        throw new ClientError(`HTTP ${r.status}`);
      }
      lastError = `HTTP ${r.status}`;
      retryAfter = r.headers.get("Retry-After");
    } catch (e) {
      if (e instanceof ClientError) throw e;
      lastError = String(e);
    }
    const delay = retryAfter
      ? Number(retryAfter) * 1000
      : Math.min(2 ** attempt, 8) * 1000;
    if (delay > MAX_WAIT_MS || attempt === MAX_ATTEMPTS - 1) break;
    await sleep(delay + Math.random() * 1000);
  }
  throw new Unavailable(lastError);
}

type Outcome =
  | { checked: true; job: any }
  | { checked: false; deliver: boolean; reason: string };

export async function check(content: string, failClosed = true): Promise<Outcome> {
  try {
    const r = await requestWithRetry("POST", "/api/v3/content/validate", {
      content,
      mode: "sync",
      model_engine: "anchor_3_0",
    });
    let job = await r.json();
    if (job.mode === "async") {
      const pollUrl: string = job.poll.url;
      const deadline = Date.now() + 120_000;
      while (!TERMINAL.has(job.status)) {
        if (Date.now() > deadline) throw new Unavailable(`timeout polling ${pollUrl}`);
        await sleep(1000);
        job = await (await requestWithRetry("GET", pollUrl)).json();
      }
    }
    if (job.status === "failed") {
      throw new Unavailable(job.error ?? "enforcement failed");
    }
    return { checked: true, job };
  } catch (e) {
    if (!(e instanceof Unavailable)) throw e;
    console.error(`ZeroDrift unchecked: ${e.message}`); // log it, and alert on the count
    return { checked: false, deliver: !failClosed, reason: e.message };
  }
}

const outcome = await check("Our fund guarantees 20% returns with zero risk!", true);
if (!outcome.checked && !outcome.deliver) {
  console.log("Held: could not check this message.");
}
```

> [!NOTE]
>
> Resending a submission after a `5xx` or a timeout can create a second job, because the first may have been accepted before the failure. Set your own message id in `metadata`, which is merged into the job, so you can spot the duplicate in your own records. Polling is safe to repeat. Do not retry `POST /api/policies/import/start` with the same `import_id`. See [Errors](https://docs.zerodrift.com/api-reference/errors.md).

## Fail open or fail closed

When a check is unavailable, you choose one of two defaults. Make the choice per use case and write it down.

| Use case | Default | Why |
| - | - | - |
| Email or letter to a client or prospect | **Fail closed.** Hold the message and retry later. | An unchecked regulated message is the exposure enforcement exists to prevent. |
| Chatbot reply to a customer | **Fail closed.** Send a safe fallback reply. | Nobody should read an unchecked reply, and the fallback keeps the conversation moving. |
| Agent action that sends or publishes | **Fail closed.** Pause the step and report it. | An agent can't tell that the check was skipped. |
| Internal chat or a draft nobody has sent | **Fail open, with a log entry.** | Blocking costs more than the risk. Review later in [Activity](https://docs.zerodrift.com/guides/export-activity.md). |
| Back-catalog scan or monitoring | **Fail open.** Record the item as unchecked and re-run it. | Nothing is being delivered. |

These are recommendations, not defaults the API applies. ZeroDrift never delivers or blocks anything for you. Your code does.

Whichever you choose:

* **Count it.** Log each unchecked item with the `job_id` if you have one, the status code or error, the attempt count, and which policy applied. Do not log the content itself. Alert when the count rises.
* **Don't hide it.** Show reviewers that a message went out unchecked. A silent fail-open looks the same as a Pass.
* **Treat `ClientError` as a bug.** A `401` or `403` after a key change is a misconfiguration, not an outage. Neither policy should absorb it.

## Do not

* Do not retry a `4xx` without changing the request or the resource.
* Do not re-send content that received a Block verdict. See [Act on Each Verdict](https://docs.zerodrift.com/guides/act-on-verdicts.md).
* Do not sleep through a `Retry-After` of `3600` inside a request. Fail per your policy and retry from a queue.
* Do not poll without a deadline. Use the 120-second deadline above, or one that fits your latency budget.

## Next

> [card] [Act on Each Verdict](https://docs.zerodrift.com/guides/act-on-verdicts.md)
>
> Decide what the user sees for Pass, Fix, Block, and Escalate.

> [card] [Errors](https://docs.zerodrift.com/api-reference/errors.md)
>
> Every status code the API returns.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.