Concepts
Errors and retries
How the API reports an error, which errors are worth retrying, and how to retry them.
The error object
A failed request returns a status of 400 or above and a JSON body with one error object. This is true for every endpoint, including the ones that return a file on success.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_options",
"message": "image.quality must be a whole number between 0 and 100.",
"param": "image.quality",
"request_id": "req_Qm4tY8sLx02BnVc7JdPe",
"doc_url": "https://smolmac.com/docs/reference/errors#invalid_options"
}
}Branch on code. It is stable. Show or log message, but do not match on its text. param names the field or option at fault, when there is one. Quote request_id when you contact support. Every code is listed in the error reference.
Failed requests are never billed.
Errors worth retrying
These can succeed if you send the same request again.
| Status | Code | What happened | What to do |
|---|---|---|---|
| 429 | rate_limited | The account sent requests faster than its plan allows. | Wait for the number of seconds in Retry-After, then retry. |
| 429 | concurrency_limited | Too many requests or jobs are in progress at once. | Wait for Retry-After, or until one of your requests finishes, then retry. |
| 503 | engine_busy | The service is at capacity for a moment. | Wait for Retry-After (one second), then retry with backoff. |
| 504 | timeout | The file took longer than a direct request allows. | Retry once. If it times out again, send the file as a job, which has a much longer time limit. |
| 500 | internal | Something went wrong on our side. | Retry with backoff. If it keeps failing, contact support with the request_id. |
| 409 | idempotency_in_progress | POST /v1/jobs was repeated with an Idempotency-Key whose first request is still being handled. | Wait for Retry-After (one second), then send the same request again. You then get the stored response of the first request. |
A request that gets no response at all, because the connection failed or timed out on your side, is also worth retrying.
Errors not worth retrying
These fail the same way every time until you change something.
| Status | Codes | What to change |
|---|---|---|
| 400 | bad_request, invalid_options, missing_file, unsupported_version | The request. message and param say what is wrong. |
| 401 | missing_key, invalid_key, revoked_key | The API key. |
| 402 | no_payment_method, payment_required, spend_cap_reached, test_mode_sample_required | The account, in the dashboard: add a payment method, pay the invoice, or raise the cap. With a test key, use a sample file. |
| 403 | account_suspended, ip_not_allowed, feature_not_enabled, invalid_signature | Contact support for a suspended account or a feature in preview, or choose an output format other than HEIC. Call from an allowed address. Fetch the job again for its download link. |
| 404 | not_found | The path or the id. |
| 409 | idempotency_conflict, job_not_finished, upload_incomplete, upload_already_completed, preset_limit_reached | Use a new idempotency key for a new request. Wait for the job to finish before asking for its receipt. Send the missing parts of an upload before completing it. Delete a preset before adding another. |
| 413 | file_too_large | Send the file as a job, or send a smaller file. |
| 415 | unsupported_input, unsupported_target, use_async_job | The file type, the target format, or the endpoint. |
| 422 | processing_failed, limit_exceeded, encrypted_pdf | The file itself. It is damaged, too large in pixels, pages or length, or password protected. |
How to retry
- Respect Retry-After. When the header is present, wait at least that many seconds.
- Back off exponentially. Without the header, wait about 1 second, then 2, 4, 8, up to a ceiling such as 30 seconds.
- Add jitter. Multiply each wait by a random factor so that many clients do not retry at the same instant.
- Stop. Give up after a fixed number of attempts, such as five, and report the last error.
- Keep the bytes. A request body that was streamed from a file cannot be sent twice. Read the file into memory, or reopen it, for each attempt.
Retrying a direct request is safe: nothing is stored, and a failed attempt is not billed. If an attempt succeeded but its response never reached you, the retry is a second successful request and both are billed. To create a job exactly once, send an idempotency key.
The SDKs retry for you. They resend a request that failed with status 429, 500, 503 or 504, or with a network error, up to twice by default. They wait for Retry-After when the response has it, and otherwise back off with jitter. See the TypeScript and Python pages.
A retry loop
import { readFile } from "node:fs/promises";
const RETRYABLE = new Set([429, 500, 503, 504]);
const MAX_ATTEMPTS = 5;
async function compress(path) {
const body = await readFile(path); // in memory, so it can be sent again
for (let attempt = 1; ; attempt++) {
const response = await fetch("https://api.smolmac.com/v1/compress", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SMOL_API_KEY}` },
body,
});
if (response.ok) return Buffer.from(await response.arrayBuffer());
const { error } = await response.json();
if (!RETRYABLE.has(response.status) || attempt === MAX_ATTEMPTS) {
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
const retryAfter = Number(response.headers.get("retry-after")) || 0;
const backoff = Math.min(30, 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.max(retryAfter, backoff)));
}
}import os, random, time
import requests
RETRYABLE = {429, 500, 503, 504}
MAX_ATTEMPTS = 5
def compress(path):
with open(path, "rb") as f:
body = f.read() # in memory, so it can be sent again
for attempt in range(1, MAX_ATTEMPTS + 1):
response = requests.post(
"https://api.smolmac.com/v1/compress",
headers={"Authorization": f"Bearer {os.environ['SMOL_API_KEY']}"},
data=body,
)
if response.ok:
return response.content
error = response.json()["error"]
if response.status_code not in RETRYABLE or attempt == MAX_ATTEMPTS:
raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})")
retry_after = float(response.headers.get("Retry-After", 0))
backoff = min(30, 2 ** (attempt - 1)) * random.uniform(0.5, 1.0)
time.sleep(max(retry_after, backoff))Jobs
A job reports a processing failure in a different place. POST /v1/jobs returns 202 once the job is accepted. If the work then fails, the job's status becomes failed and its error field holds type, code and message. The codes are the same ones, plus a few that only jobs can have, such as input_fetch_failed.
- The API already starts a job again on its own when the engine working on it restarts. It gives up after three attempts, and the job then fails with
internal. A fault inside the engine while it works on the file ends the job withinternaltoo. - A job that failed with
timeoutorinternalcan be tried again by creating a new job. Other codes need a different file, different options, or a feature enabled on the account. - A job deletes its input when it finishes, whatever the outcome. To try again with an uploaded file, upload the file again and create the job with the new upload id.
- A failed job is not billed.