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:
return c.json({ error, fix, ...extra }, status);
The fields have separate purposes:
statusgives the HTTP class and standard transport meaning.errorgives a stable machine-readable branch key.fixgives a plain-language next action.- Extra fields provide structured data for that specific error.
Repository evidence:
worker/lib.tsworker/index.tsworker/openapi.tsworker/agent-docs.tstests/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_largecode. - The exact
fixstring.
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
{
"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
{
"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
{
"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
{
"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
{
"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.
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_requiredmeans the account has no usable storage.quota_exceededmeans 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 for the complete runnable policy. Read upload an image with JavaScript and Node.js for a basic client.
For an agent-led setup, use /integrate.md.
For a human setup, open Get started.