> An installable agent skill for AI Content Drop. Machine-readable copy: > https://aicontentdrop.com/skills/api-error-handling/SKILL.md. > Ships with the aicontentdrop package, and is listed in > /skills/index.json.
Failing well against the AI Content Drop API
The point of this skill is to stop you doing the two things that waste a user's money and time: retrying something that will never succeed, and giving up on something that would have worked in thirty seconds.
The envelope
Every failure on the public surface carries a machine-readable code:
{
"error": {
"code": "insufficient_credits",
"message": "Balance below the model cost.",
"documentation": "https://aicontentdrop.com/docs/errors"
}
}
Read error.code. Never branch on the message — it is written for a human and will change.
One exception you must handle. POST /v1/generate/video and /v1/generate/image are path aliases onto the handlers the website itself calls, so a *validation* failure from those two can still arrive in the older shape, { "error": "some message" } — a string, not an object. Write the read once and forget it:
const code = typeof body.error === "object" ? body.error.code : undefined;
const message = typeof body.error === "string" ? body.error : body.error?.message;
What each code means for your next move
Do not retry — fix the request:
| Code | Status | What to do |
|---|---|---|
invalid_request |
400 | The message names the field. Correct it. |
invalid_json |
400 | Send a complete object with Content-Type: application/json. |
safety_rejected |
400 | The prompt was refused. Rewrite it. Nothing was charged. |
unsupported_operation |
400 | Not available inside a batch. Call the endpoint directly. |
unknown_model |
404 | Re-read /v1/models. Check id normalisation (below). |
unknown_endpoint |
404 | GET /v1 lists the whole surface. |
method_not_allowed |
405 | The message names the verbs that work. |
forbidden |
403 | The object is not yours. Retrying cannot change that. |
payload_too_large |
413 | Split the work. See POST /v1/batch. |
query_too_large |
413 | GraphQL query over 8,000 characters. Split it. |
Do not retry — the user has to act:
| Code | Status | What to do |
|---|---|---|
unauthorized |
401 | No key, or a revoked or malformed one. Read WWW-Authenticate. |
unauthenticated |
401 | A session surface reached without a session. Most of /v1 needs no credential. |
invalid_client |
401 | Re-register at /agent/auth/register. |
insufficient_credits |
402 | Tell the user the balance and the cost. Offer a cheaper model. |
idempotency_conflict |
409 | Same Idempotency-Key, different body. Use a new key. |
Retry is correct:
| Code | Status | What to do |
|---|---|---|
rate_limited |
429 | Wait Retry-After seconds exactly. Nothing was charged. |
provider_error |
502 | The upstream model failed. Retry is reasonable. Nothing was charged. |
internal_error |
500 | Our bug. Retry once, then report it. |
not_found (404) and operation_failed (400, one item inside a batch) are context-dependent: re-read the catalogue, or read that item's own error.
Rate limits
| Surface | Limit per minute |
|---|---|
/v1/* REST |
120 |
POST /graphql |
120 |
POST /mcp, POST /mcp/docs |
600 |
POST /ask |
60 |
Every response carries the counters, not just the 429:
RateLimit-Limit: 120
RateLimit-Remaining: 118
RateLimit-Reset: 47
Self-throttle on RateLimit-Remaining instead of waiting to be refused. You can watch them with no credential at all:
curl -sD - https://aicontentdrop.com/v1 -o /dev/null | grep -i ratelimit
When you are refused, a 429 carries Retry-After in seconds and retry_after_seconds in the body. Wait that long. Retrying sooner extends the window rather than shortening it, so an impatient loop is strictly slower than a patient one.
The rule that makes failure cheap here
Credits are charged only when a generation succeeds. A safety block, a provider failure, a validation error and a timeout all cost nothing. There is no refund path anywhere in the product because there is nothing to refund.
Two consequences for your error handling:
- Never tell a user they were charged for a failed generation. They were not.
- Never write code that asks for a refund or a credit adjustment. No such
endpoint exists, by design.
A generation that fails *after* it started shows status: "failed" with credits_used: 0 and an error_message you can read.
Rehearse instead of guessing
If you are unsure a request is well formed, run it through the sandbox first. It applies the full validation path, needs no API key, and charges nothing:
curl -sX POST https://aicontentdrop.com/v1/generate/video \
-H "Content-Type: application/json" \
-H "X-Sandbox: true" \
-d '{"prompt":"a red balloon over a city","model":"kling_3_0"}'
A 202 means the request shape is right. A 400 names what is wrong, before anyone's credits are involved.
Model id normalisation
Ids use underscores (kling_3_0, veo_3_fast). Dashes are accepted and normalised, so kling-3-0 works. If a model still comes back unknown_model, re-read /v1/models rather than guessing a variant — the catalogue is the source of truth and needs no credential.
Retrying without double-spending
Put an Idempotency-Key on every generation request. Retention is 24 hours: the same key with the same body returns the original result instead of starting a second generation; the same key with a *different* body is a 409 idempotency_conflict.
curl -sX POST https://aicontentdrop.com/v1/generate/video \
-H "Authorization: Bearer $ACD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"prompt":"…","model":"kling_3_0"}'
This is what makes a network timeout safe to retry. Without it, a retry after a timeout can start a second paid generation.
Full reference: <https://aicontentdrop.com/docs/errors>
---
Install the plugin: https://aicontentdrop.com/plugin · All skills: https://aicontentdrop.com/skills