---
title: "How Actionable API Errors Help AI Agents Recover"
description: "Use HTTP status, stable error codes, and verbatim fix text to recover safely from imgd.dev API failures."
slug: actionable-api-errors-ai-agents
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Branch on both HTTP status and the stable error code. Show the server fix verbatim, and retry only transient failures."
cluster: Engineering
intent: engineering
sources:
  - title: "imgd.dev agent reference"
    url: https://imgd.dev/llms.txt
  - title: "imgd.dev OpenAPI specification"
    url: https://imgd.dev/openapi.json
---

# How Actionable API Errors Help AI Agents Recover

Branch on both the HTTP status and the stable `error` code.
Show the server `fix` text verbatim.
Retry only network failures, `429`, and bounded server failures.
Do not retry every `4xx` response.
A stable code authorizes program logic, while `fix` gives the next action to the user or agent.

Every imgd.dev image is public.
Do not upload private, confidential, or secret material after any error.

## The response contract

The shared helper in `worker/lib.ts` has this form:

```ts
return c.json({ error, fix, ...extra }, status);
```

The fields have separate purposes:

- `status` gives the HTTP class and standard transport meaning.
- `error` gives a stable machine-readable branch key.
- `fix` gives a plain-language next action.
- Extra fields provide structured data for that specific error.

Repository evidence:

- `worker/lib.ts`
- `worker/index.ts`
- `worker/openapi.ts`
- `worker/agent-docs.ts`
- `tests/moderation.test.mjs`
- Git commit `cfc278d`

## Implementation facts

The `fail()` helper accepts the current client-error statuses used by the Worker.
It always places `error` and `fix` before any extra response fields.

The tests check that a `413` response preserves all three parts:

- The HTTP status.
- The `file_too_large` code.
- The exact `fix` string.

The agent integration document also tells callers to surface `fix` verbatim.
It warns against a blind retry.

## Measured production facts

This article does not claim a measured recovery-rate improvement.
The repository states the design goal, but it does not include an agent recovery benchmark.

The facts below come from current code, tests, and public response contracts.
The design rationale appears in a separate section.

## Representative error bodies

These examples use current response fields and current limits.
Dynamic values can differ by account state and file size.

### `401` missing key

```json
{
  "error": "missing_api_key",
  "fix": "send your key as 'Authorization: Bearer <api_key>'; create one with POST /v1/accounts"
}
```

Do not retry the same request.
Add the header with `$IMGD_KEY`, then start a new request.

### `402` quota exceeded

```json
{
  "error": "quota_exceeded",
  "fix": "this 10.0 MB upload needs more room than the 0 bytes left on your 1 GB quota; POST https://imgd.dev/v1/credit with {\"gb\":1} to buy 1 GB for $1.00, then retry this upload, or DELETE images you no longer need",
  "topup_url": "https://imgd.dev/v1/credit",
  "suggested_gb": 1,
  "incoming_bytes": 10485760,
  "storage_remaining_bytes": 0
}
```

Do not retry until the caller buys storage or deletes images.
imgd.dev charges a one-time $1 per GB and has no free tier.

### `413` file too large

```json
{
  "error": "file_too_large",
  "fix": "file exceeds 20MB; resize the image below that and retry",
  "bytes": 20971521,
  "max_bytes": 20971520
}
```

Do not retry the same bytes.
Resize the image first.

### `415` unsupported media type

```json
{
  "error": "unsupported_media_type",
  "fix": "'image/svg+xml' is not accepted; send jpeg, png, gif, webp, or avif and set the matching Content-Type",
  "accepted": [
    "image/jpeg",
    "image/png",
    "image/gif",
    "image/webp",
    "image/avif"
  ]
}
```

Do not retry the same request.
Convert the image or correct the media type first.

### `429` rate limit

```json
{
  "error": "rate_limited",
  "fix": "you exceeded 60 uploads/minute; wait until the next minute and retry"
}
```

This response can receive a bounded retry after the stated wait.
Do not turn it into an infinite loop.

## A safe status-and-code branch

This example branches on both values.
It writes `fix` exactly as the server returned it.

```js
const TRANSIENT_STATUS = new Set([429, 500, 502, 503, 504]);
const MAX_ATTEMPTS = 4;

class ApiFailure extends Error {
  constructor({ status, error, fix }) {
    super(fix);
    this.status = status;
    this.error = error;
    this.fix = fix;
  }
}

async function readJsonObject(response) {
  try {
    const body = await response.json();
    return body && typeof body === "object" ? body : {};
  } catch {
    return {};
  }
}

function callerAction(status, error) {
  if (status === 401 && ["missing_api_key", "invalid_api_key"].includes(error)) {
    return "change_key";
  }
  if (status === 402 && ["payment_required", "quota_exceeded"].includes(error)) {
    return "change_account_state";
  }
  if (status === 413 && error === "file_too_large") {
    return "resize_file";
  }
  if (status === 415 && error === "unsupported_media_type") {
    return "convert_file";
  }
  if (status === 429 && ["rate_limited", "http_429"].includes(error)) {
    return "retry_later";
  }
  if (status >= 500) return "retry_later";
  return "inspect_and_change_request";
}

async function uploadWithPolicy(makeRequest) {
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
    const request = makeRequest();
    let response;
    try {
      response = await fetch("https://imgd.dev/v1/upload", request);
    } catch (error) {
      if (attempt === MAX_ATTEMPTS) throw error;
      await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
      continue;
    }

    const body = await readJsonObject(response);
    if (response.ok) return body;

    const serverError = typeof body.error === "string"
      ? body.error
      : `http_${response.status}`;
    const serverFix = typeof body.fix === "string"
      ? body.fix
      : `inspect HTTP ${response.status} before another request`;
    const failure = new ApiFailure({
      status: response.status,
      error: serverError,
      fix: serverFix,
    });

    process.stderr.write(`${failure.fix}\n`);
    const action = callerAction(failure.status, failure.error);

    if (action !== "retry_later" || !TRANSIENT_STATUS.has(failure.status)) {
      throw failure;
    }
    if (attempt === MAX_ATTEMPTS) throw failure;

    await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
  }
}
```

The callback must build a new `FormData` object for each attempt.
It must reuse the same image bytes after an uncertain network result.
A non-JSON `429` or selected `5xx` response still enters the bounded status branch.
The maintained example is `examples/engineering/retry-upload.mjs`.

## Why status and error must both participate

The status alone is too broad.
Two `402` responses can require different actions:

- `payment_required` means the account has no usable storage.
- `quota_exceeded` means the account needs more room or fewer stored images.

The `error` alone also lacks the standard HTTP class.
A caller should retain both values in logs and exceptions.
It must never log `$IMGD_KEY` or an authorization header.

## Why `fix` must remain verbatim

A paraphrase can remove the exact endpoint, limit, accepted format, or wait action.
The server has more context than a generic client message.

Use `fix` for these outputs:

- A terminal message for a person.
- An agent response to its operator.
- A structured exception message.
- A CI summary that contains no authorization header or sensitive request data.

Do not use `fix` as the only branch key.
Prose can change while the stable `error` code remains suitable for logic.

## Design rationale

Automated callers need two layers of meaning.
The stable code supports deterministic software behavior.
The plain-language action supports recovery when software cannot complete the change itself.

This split keeps the client small.
The client does not need to duplicate every server limit or payment instruction.
It still controls risk because retries depend on status and code, not prose.

A bounded retry policy also limits duplicate traffic during an outage.
Content-addressed uploads make an uncertain retry safe when the bytes stay identical.

## Recovery matrix

| Status and code | Automatic retry | Required action |
| --- | --- | --- |
| `401 missing_api_key` | No | Add the bearer header. |
| `401 invalid_api_key` | No | Replace the key or create a new account. |
| `402 payment_required` | No | Fund the account. |
| `402 quota_exceeded` | No | Buy storage or delete images. |
| `403 account_suspended` | No | Stop and use the appeal path. |
| `403 content_blocked` | No | Do not re-upload the same bytes. |
| `413 file_too_large` | No | Resize below 20 MB. |
| `415 unsupported_media_type` | No | Convert to an accepted image format. |
| `429 rate_limited` | Yes, bounded | Wait until the next minute. |
| `5xx` | Yes, bounded | Retry with backoff, then report failure. |
| Network failure | Yes, bounded | Retry identical bytes, then report failure. |

Do not retry `401`, `413`, or `415` without a caller change.
Do not retry every `4xx` response.

## Fit and security

This contract fits agents, CLIs, and CI jobs that can branch on structured JSON.
It also fits human tools that need a clear error message.

imgd.dev does not fit private images, a free trial, video, or full DAM.
Every image that imgd.dev serves is public.

Read [safe content-addressed upload retries](/blog/image-upload-retry-content-hash/) for the complete runnable policy.
Read [upload an image with JavaScript and Node.js](/blog/upload-image-javascript-nodejs/) for a basic client.

For an agent-led setup, use [`/integrate.md`](/integrate.md).
For a human setup, open [Get started](/#start).
