Guides
Errors
Errors are RFC 9457 problem documents. Branch on the stable code, never on the text, and use the retry table to decide whether to try again.
The problem document
Link to this section: The problem documentAn error from the API has the content type application. code is stable and meant for control flow; type is the same code as a URI, https://, that leads to its documentation page, and title is its short English name. For a given code, type, title and code never change. Branch on code, not on the URI.
{
"type": "https://docs.filmo.io/errors/validation_error",
"title": "Validation error",
"status": 400,
"code": "validation_error",
"detail": "2 validation errors",
"errors": [
{ "path": "lines[0].itemId", "code": "not_found", "message": "The lines[0].itemId identifier does not exist or does not belong to this account" },
{ "path": "endDate", "code": "must_be_after", "message": "endDate must be after startDate" }
],
"requestId": "req_5f1c2f4e3a5b4f6d8e7a0b1c2d3e4f5a"
}| Property | Meaning |
|---|---|
type, title | The code as a URI, and its short English name. |
status | The HTTP status of the response. |
code | The stable error code. Map it in your client. |
detail | An explanation of this occurrence, for people. Do not use it for control flow. |
requestId | The identifier of the request; also the X-Request-Id header. Quote it when you contact support. |
errors | On validation_error, every offending field; on contact_in_use, address_in_use and insufficient_stock, what blocks the operation; on conflict, the cause in errors[0].. |
requiredScope | On insufficient_scope: the scope the key needs. |
requiredAddon | On addon_required: the add-on the module needs. |
retryAfter | On rate_limited: the seconds to wait before retrying, the same value as the Retry-After header. |
Index of error codes
Link to this section: Index of error codes| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The request has no Authorization: Bearer credential. |
| 401 | api_key_invalid | The key is not recognized. |
| 401 | api_key_revoked | The key was revoked. |
| 401 | api_key_expired | The key has expired. |
| 401 | api_key_owner_inactive | The administrator who owns the key left the account or was deactivated. |
| 401 | api_key_owner_not_admin | The owner of the key is no longer an administrator. |
| 403 | insufficient_scope | The key does not carry the scope the operation requires. |
| 403 | forbidden | The owner of the key lacks the module, organization or administrator permission the operation requires. |
| 402 | addon_required | The account has not contracted, let expire, or lost with the end of the free trial, the add-on of that module. |
| 400 | validation_error | The query or the body is invalid. errors[] lists every offending field. |
| 422 | idempotency_key_reused | The Idempotency-Key was already used with a different body. |
| 404 | not_found | The resource does not exist or is outside the tenancy of the key. The two are indistinguishable. |
| 409 | conflict | A request with that key is still in progress (in_progress), a conditional write held the resource for more than 5 seconds (lock_timeout), or the state of the resource prevents the operation (invalid_state). The cause is in errors[0].. |
| 409 | contact_in_use | Other records still use the contact; errors[] counts them per type. |
| 409 | address_in_use | Orders still use the address as their billing or shipping address. |
| 409 | insufficient_stock | There is not enough stock for the request; errors[] has one entry per short item. |
| 412 | precondition_failed | The If-Match tag no longer matches: the resource changed since you read it. |
| 429 | rate_limited | A rate limit was exceeded; Retry-After, repeated in retryAfter, says how long to wait. |
| 500 | internal_error | An internal error. It is also the answer if the store that counts rate limits fails: the API does not skip the limit in that case, so retry with backoff. |
| 503 | service_unavailable | The feature is switched off for the account. Retrying does not help until it is switched on. |
The 401 codes are checked in a fixed order: no bearer credential (unauthorized), then an unrecognized key (api_key_invalid), then a revoked one, an expired one and finally an owner who is inactive or no longer an administrator. A 401 and a 403 insufficient_scope do not consume rate-limit quota; every other response of an authenticated key does.
Each code has its own page with the operations that return it and what to do. Authentication codes are also covered in Authentication.
Three kinds of “you cannot”
Link to this section: Three kinds of “you cannot”Three different rejections look alike and are fixed in three different places. Sending all of them to the same place sends you debugging in the wrong one.
| Status and code | It is about | What is wrong | Who fixes it |
|---|---|---|---|
403 insufficient_scope | The key | The key does not carry the scope the endpoint requires; requiredScope names it. | Reissue the key with that scope. |
403 forbidden | The owner of the key | The key is correct, but its owner lacks the module, organization or administrator permission the operation demands. | An administrator of the account grants the permission. |
402 addon_required | The add-on | The account has not contracted, or let expire, the add-on of the module; requiredAddon names it. No role bypasses it. The API and MCP are not an add-on. | Whoever manages the subscription. |
Help Center articles: API scopes and permissions, Roles and permissions and Your subscription. See also Scopes.
Details of the errors that carry more
Link to this section: Details of the errors that carry morevalidation_error
Link to this section: validation_errorerrors[] lists every offending field, not only the first. path uses dot and bracket notation (lines[0].) and is empty when the whole body is the problem. The field code is stable. These are the codes seen so far; the list is not exhaustive, so handle an unknown one as a generic invalid field:
required,must_not_be_nullinvalid_type,invalid_value,invalid_formattoo_small,too_bigunknown_fieldnot_foundmust_be_after
A body that references an identifier outside your tenancy, such as the contact of an order, is a 400 naming the field, and nothing is written. The message is the same whether the identifier does not exist or belongs to another account, so it cannot be used to probe either. Reading a resource outside your tenancy is a 404 identical to it not existing.
conflict
Link to this section: conflictA 409 conflict says why in errors[0].. The cause decides whether waiting helps:
errors[0]. | Cause | What to do |
|---|---|---|
in_progress | Another request with the same Idempotency-Key is still running. | Wait and retry with the same key. Do not send a second attempt in parallel. |
lock_timeout | Another conditional (If-Match) write held the resource for more than 5 seconds. | Retry with backoff. If it keeps happening, read the resource. |
invalid_state | The state of the resource forbids the operation, or the write broke a uniqueness rule. A line of an atomic batch also reports it when the line is in a wrong state; a missing line or an invalid quantity reports not_found or validation_error. | Do not retry: waiting does not change the state. Read the resource and decide. |
Retry in_progress and lock_timeout at most 3 times with exponential backoff and the same key, then read the resource. Treat a 409 conflict with any other errors[0]., or with none, like invalid_state after those 3 retries.
contact_in_use and address_in_use
Link to this section: contact_in_use and address_in_useerrors[] has one entry per type of record that uses the contact or the address: path is the type and count how many records of that type use it. The values of path come from a closed set, listed in the x-path-values of the path property in the spec: for contact_in_use, orders, projects, quotes, invoices, proformas, creditNotes, simpleCreditNotes, salesReceipts, supplierInvoices, contracts, subrents, purchaseOrders, sepaMandates, receipts and consignments; for address_in_use, orders. The set may grow, so tolerate a value you do not know. When anything uses the contact, nothing is deleted.
insufficient_stock
Link to this section: insufficient_stockOne entry per item, or per space, that is short. path is items or spaces, and each entry has itemId, requested, available and shortfall.
Language of the messages
Link to this section: Language of the messagesdetail and every errors[]. come in the language negotiated with the Accept-Language header. es-ES and en-US are guaranteed; any other supported language falls back to English. Without the header, a request made with a key that exists uses the preferred language of the key owner, also when the error is a 401 for a revoked or expired key or a 403 insufficient_scope. A request with no credential, or with a key Filmo does not recognise, has no owner to ask: its 401 comes in Spanish unless you send Accept-Language. Always send the header. Content-Language tells you which one the response used. Never write logic that matches on those texts. See Languages.
Responses that do not come from the API
Link to this section: Responses that do not come from the APISome failures happen before the request reaches the API or after it leaves it: a function that times out (504), a request body larger than the platform accepts (413), a bad gateway (502) or a firewall challenge. They arrive without a problem document, so there is no code and no requestId. You recognize them because the content type is not application. Do not try to read code from them: decide by the HTTP status.
- 502 and 504: the outcome is unknown. Retry with the same
Idempotency-Key; on a write without a key, read the resource first. - 413: the body is too large. Retrying does not help; send less in each request.
- The platform generates 413, 502 and 504; the API never sends them as a problem document. Do not depend on any platform-specific header to recognize these responses: any 5xx without
applicationis one of them./problem+json - Timeouts:
POST /apimay run for up to 300 seconds, so give that call a long client timeout. For every other route, Filmo documents no limit; set your own client timeouts and handle a 504./v1 /ai /ask
Retry decisions
Link to this section: Retry decisionsOne policy per status is not enough. The same 409 can mean wait and retry or never retry, a 500 can repeat for 24 hours, and a 207 is not fixed by resending the same request. For a 409 conflict, read the cause in errors[0]. first (see above). Then find the first row that matches your response.
| Status | Idempotency-Key | Idempotent-Replay: true | What to do |
|---|---|---|---|
| 409 conflict · in_progress | Yes | No | errors[0]. is in_progress: another request with the same Idempotency-Key is still running. Wait and retry with the same key (exponential backoff, up to 3 times): you get its stored response when it finishes. If the 409 persists for about two minutes, the reservation is treated as abandoned and the next attempt with the same key runs; read the resource before retrying. |
| 409 conflict · lock_timeout | — | No | errors[0]. is lock_timeout: another write held the resource, or the database aborted the transaction on a transient conflict; nothing was written. Retry with the same key and an exponential backoff, up to 3 times; if it persists, read the resource again. |
| 409 conflict · invalid_state | — | — | errors[0]. is invalid_state: the state of the resource forbids the operation (a unique violation, a line of an atomic batch, an item deleted while saving). Do not retry unchanged: waiting never fixes it. Read the resource again; if the request still fits, you may send it again. |
| 409 contact_in_use · address_in_use · insufficient_stock | — | — | Do not retry. errors[] says what blocks the operation: fix that first. |
| 412 precondition_failed | — | — | Read the resource again, redo your change on top of it and send the new ETag. |
| 422 idempotency_key_reused | Yes | — | A bug in the client: the key was used with a different request (a different method, path or body, compared byte by byte). Use a new key only if it really is a different operation. |
| 429 rate_limited | — | — | Wait the seconds in Retry-After and retry with the same key. |
| 500 internal_error | Yes | Yes | Stop. The failure happened after the commit and is stored and repeated for 24 h with that key: read the resource to see where it ended up. |
| 500 internal_error | Yes | No | Retry with exponential backoff and the same key. |
| 500 internal_error | No | — | Read before retrying a write: without a key nothing protects you from a duplicate. |
| 503 service_unavailable | — | — | Do not retry: the feature is switched off for the account. |
207 (returnOrderLineItems with atomic: false) | Yes | — | The lines that failed were not returned and the rest were. Resend only the failed ones with a new key; repeating the key would return the same 207. |
413 · 502 · 504 (not application) | Yes | — | An answer from the platform, not from the API: decide by the status and retry with the same key. A 413 is not fixed by retrying (the body is too large). |
| Any validation 4xx | — | — | Fix the request and resend it; never in a loop. |
| 401 · 402 · 403 (key, owner or plan) | — | — | Do not retry: resending the same request fails again. Act on the key, its owner or the plan: use a valid key with the right scope, ask an administrator for the missing permission, or add the add-on. |
The key is what makes retries safe: see Idempotency. For writes that use If-Match, see Concurrency.
Reference retry helper
Link to this section: Reference retry helperThis helper wraps any call and applies the decisions above. Copy it, change the name of the environment variables if you keep your key elsewhere, and call it instead of fetch, requests or curl.
- Every write that declares the
Idempotency-Keyheader carries one: the one you pass, or a new UUID. The same key is sent on every retry of that call. The helper sends none on the query POSTs (the availability checks, the contacts lookup andPOST /api), which do not declare the header and ignore it./v1 /ai /ask - 429: wait
Retry-Afterseconds and retry, up to 5 times. - 409
conflict: onin_progressandlock_timeout, up to 3 retries with exponential backoff, then it stops and tells you to read the resource; oninvalid_stateit stops at once and tells you the same. - 500 with
Idempotent-Replay: true: stops at once and tells you to read the resource. - 500 without that header: up to 3 retries with exponential backoff and the same key. On a write sent without a key, it stops and tells you to read the resource.
- 502, 503 and 504 without a problem document, and network failures: handled as a 500 without the replay header.
- Everything else fails immediately, with the status and
code, and anactionthat says what to do next.
#!/usr/bin/env bash
# filmo-request.sh: one function for any call to the Filmo REST API.
# Needs curl and jq, and FILMO_API_KEY; FILMO_BASE_URL is optional.
#
# filmo_request METHOD PATH [JSON_BODY] (put the query string in PATH)
#
# Writes get an Idempotency-Key: IDEMPOTENCY_KEY if set, else a fresh UUID. The
# query POSTs (availability checks, contacts lookup, ai ask) do not declare the header, so they get none.
# Set NO_IDEMPOTENCY_KEY=1 to send none. The body goes to stdout when the call succeeds.
# Optional: ACCEPT_LANGUAGE (e.g. en) sets the language of error messages; IF_MATCH sends an ETag.
# Exit code: 0 done; 10 fix the request; 11 read the resource before anything else;
# 12 read again and redo the change (412); 13 give up.
FILMO_MAX_RETRIES=3
filmo_request() {
local method="$1" path="$2" body="$3"
local base="${FILMO_BASE_URL:-https://app.filmo.io}"
local is_write=0 key="" retries=0 waits=0
[ "$method" != "GET" ] && is_write=1
case "${path%%[?]*}" in
/api/v1/availability/check | /api/v1/staff/availability | /api/v1/vehicles/availability | /api/v1/contacts/lookup | /api/v1/ai/ask) is_write=0 ;;
esac
if [ "$is_write" = 1 ] && [ "$NO_IDEMPOTENCY_KEY" != 1 ]; then
key="${IDEMPOTENCY_KEY:-$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)}"
fi
local hdr out
hdr="$(mktemp)"
out="$(mktemp)"
while :; do
set -- -sS -X "$method" -D "$hdr" -o "$out" -w '%{http_code}' \
-H "Authorization: Bearer $FILMO_API_KEY" -H "Accept: application/json"
[ -n "$key" ] && set -- "$@" -H "Idempotency-Key: $key" # the same key on every retry
[ -n "$ACCEPT_LANGUAGE" ] && set -- "$@" -H "Accept-Language: $ACCEPT_LANGUAGE"
[ -n "$IF_MATCH" ] && set -- "$@" -H "If-Match: $IF_MATCH"
[ -n "$body" ] && set -- "$@" -H "Content-Type: application/json" --data "$body"
local status
status="$(curl "$@" "$base$path")" || status=000 # network failure: it may or may not have arrived
local problem=0 code="" cause="" replay="" wait=""
if [ "$status" != 000 ]; then
grep -qi '^content-type:.*application/problem+json' "$hdr" && problem=1
if [ "$problem" = 1 ]; then
code="$(jq -r '.code // empty' "$out" 2>/dev/null)"
cause="$(jq -r '.errors[0].code // empty' "$out" 2>/dev/null)" # why a 409 happened
fi
grep -qi '^idempotent-replay: true' "$hdr" && replay=true
wait="$(grep -i '^retry-after:' "$hdr" | tail -1 | tr -d '\r' | awk '{print $2}')"
fi
if [ "$status" != 000 ] && [ "$status" -lt 400 ]; then
cat "$out"
rm -f "$hdr" "$out"
return 0
fi
if [ "$status" = 429 ]; then
# Wait as long as the server says, then retry with the same key.
[ "$waits" -ge 5 ] && { _filmo_fail "$status" "$code" "$out" "$hdr"; return 13; }
waits=$((waits + 1))
sleep "$([ "${wait:-0}" -gt 0 ] 2>/dev/null && echo "$wait" || echo 1)"
continue
fi
if [ "$status" = 409 ] && [ "$code" = conflict ]; then
# errors[0].code says why. in_progress and lock_timeout clear by waiting;
# invalid_state (the state of the resource forbids the call) never does.
if [ "$cause" = invalid_state ] || [ "$retries" -ge "$FILMO_MAX_RETRIES" ]; then
_filmo_fail "$status" "$code" "$out" "$hdr"
return 11
fi
sleep $((1 << retries)) # 1 s, 2 s, 4 s
retries=$((retries + 1))
continue
fi
# A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
if [ "$status" = 500 ] || [ "$status" = 000 ] ||
{ [ "$problem" = 0 ] && { [ "$status" = 502 ] || [ "$status" = 503 ] || [ "$status" = 504 ]; }; }; then
# The failure happened after the commit and is stored for 24 h: retrying returns it again.
if [ "$replay" = true ] || { [ "$is_write" = 1 ] && [ -z "$key" ]; }; then
_filmo_fail "$status" "$code" "$out" "$hdr"
return 11
fi
if [ "$retries" -ge "$FILMO_MAX_RETRIES" ]; then
_filmo_fail "$status" "$code" "$out" "$hdr"
return 13
fi
sleep $((1 << retries))
retries=$((retries + 1))
continue
fi
_filmo_fail "$status" "$code" "$out" "$hdr"
if [ "$status" = 412 ]; then return 12; fi
if [ "$status" -ge 500 ]; then return 13; fi
return 10
done
}
_filmo_fail() { # status code body-file header-file
local rid
rid="$(grep -i '^x-request-id:' "$4" 2>/dev/null | tr -d '\r' | awk '{print $2}')"
echo "filmo: HTTP $1 ${2:+($2) }request $rid" >&2
cat "$3" >&2 2>/dev/null
rm -f "$3" "$4"
}
# 207 (returnOrderLineItems with atomic false): resend only the lines that failed,
# each round with a NEW key, because the same key would replay the same 207.
# Usage: return_lines '[{"lineItemId":"...","checkInQuantity":1,"returnCondition":"good"}]'
return_lines() {
local pending="$1" round res
for round in 1 2 3; do
[ "$(echo "$pending" | jq 'length')" = 0 ] && break
res="$(IDEMPOTENCY_KEY="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)" \
filmo_request POST /api/v1/orders/line-items/returns \
"$(jq -c '{items: ., atomic: false}' <<<"$pending")")" || return $?
# Keep only the elements that failed (their index in what we sent).
pending="$(jq -c --argjson sent "$pending" \
'[.data[] | select(.status == "failed") | $sent[.index]]' <<<"$res")"
done
echo "$pending" # the lines that still failed; [] when everything was returned
}// filmo-request.mjs: one function for any call to the Filmo REST API.
// Node 22+, no dependencies. Needs FILMO_API_KEY; FILMO_BASE_URL is optional.
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const MAX_RETRIES = 3; // conflicts, 500s and platform errors
const MAX_RATE_LIMIT_WAITS = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 0.5 s, 1 s, 2 s... with jitter, capped at 30 s.
const backoff = (retry) => Math.min(30_000, 500 * 2 ** retry) * (0.5 + Math.random() / 2);
export class FilmoError extends Error {
// action: "fix-request" | "read-resource" | "reread-and-redo" | "give-up"
constructor(message, { action, status, problem, requestId }) {
super(message);
Object.assign(this, { action, status, problem, requestId });
}
}
// Query POSTs do not declare Idempotency-Key, so they never get one.
const QUERY_POSTS = [
"/api/v1/availability/check",
"/api/v1/staff/availability",
"/api/v1/vehicles/availability",
"/api/v1/contacts/lookup",
"/api/v1/ai/ask",
];
export async function filmoRequest(method, path, { query, body, idempotencyKey, ifMatch, language, headers: extra } = {}) {
const isWrite = method !== "GET" && !QUERY_POSTS.includes(path.split("?")[0]);
// Every write that declares the header carries a key. Pass idempotencyKey: null to send none on purpose.
const key = !isWrite ? null : idempotencyKey === undefined ? crypto.randomUUID() : idempotencyKey;
const url = new URL(path, BASE);
for (const [name, value] of Object.entries(query ?? {})) {
for (const v of [].concat(value)) url.searchParams.append(name, String(v));
}
const headers = { Authorization: `Bearer ${process.env.FILMO_API_KEY}`, Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (key) headers["Idempotency-Key"] = key; // the same key on every retry of this call
if (ifMatch) headers["If-Match"] = ifMatch; // the ETag you read
if (language) headers["Accept-Language"] = language; // language of error messages, e.g. "en"
Object.assign(headers, extra);
let retries = 0;
let waits = 0;
for (;;) {
let res = null;
try {
res = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
// Network failure: the request may or may not have arrived.
}
const status = res ? res.status : 0;
const text = res ? await res.text() : "";
if (res && status < 400) {
return { status, headers: res.headers, body: text ? JSON.parse(text) : null };
}
let problem = null;
if (res?.headers.get("content-type")?.includes("application/problem+json")) {
try {
problem = JSON.parse(text);
} catch {}
}
const fail = (action) =>
new FilmoError(method + " " + path + " -> " + (status || "network error") + (problem ? " " + problem.code : ""), {
action,
status,
problem,
requestId: res?.headers.get("x-request-id") ?? problem?.requestId,
});
if (status === 429) {
// Wait as long as the server says, then retry with the same key.
if (waits++ >= MAX_RATE_LIMIT_WAITS) throw fail("give-up");
const seconds = Number(res.headers.get("retry-after"));
await sleep((seconds > 0 ? seconds : 1) * 1000);
continue;
}
if (status === 409 && problem?.code === "conflict") {
// errors[0].code says why. "in_progress" and "lock_timeout" clear by waiting;
// "invalid_state" (the state of the resource forbids the call) never does.
const cause = problem.errors?.[0]?.code;
if (cause === "invalid_state" || retries >= MAX_RETRIES) throw fail("read-resource");
await sleep(backoff(retries++));
continue;
}
// A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
const platform = status === 0 || (!problem && [502, 503, 504].includes(status));
if (status === 500 || platform) {
// The failure happened after the commit and is stored for 24 h: retrying returns it again.
if (res?.headers.get("idempotent-replay") === "true") throw fail("read-resource");
// Without a key a retry could write twice.
if (isWrite && !key) throw fail("read-resource");
if (retries >= MAX_RETRIES) throw fail("give-up");
await sleep(backoff(retries++));
continue;
}
if (status === 412) throw fail("reread-and-redo");
throw fail(status >= 500 ? "give-up" : "fix-request"); // validation, 413, 422, 503 service_unavailable...
}
}
// 207 (returnOrderLineItems with atomic: false): resend only the lines that failed,
// each round with a NEW key, because the same key would replay the same 207.
export async function returnLines(items, rounds = 3) {
let pending = items;
const returned = [];
for (let round = 0; round < rounds && pending.length > 0; round++) {
const res = await filmoRequest("POST", "/api/v1/orders/line-items/returns", {
body: { items: pending, atomic: false },
idempotencyKey: crypto.randomUUID(),
});
const failed = [];
for (const result of res.body.data) {
if (result.status === "returned") returned.push(result.id);
else failed.push(pending[result.index]);
}
pending = failed;
}
return { returned, failed: pending };
}# filmo_request.py: one function for any call to the Filmo REST API.
# Python 3 with requests. Needs FILMO_API_KEY; FILMO_BASE_URL is optional.
import os
import random
import time
import uuid
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
MAX_RETRIES = 3 # conflicts, 500s and platform errors
MAX_RATE_LIMIT_WAITS = 5
_UNSET = object()
class FilmoError(Exception):
"""action: fix-request | read-resource | reread-and-redo | give-up"""
def __init__(self, message, action, status, problem, request_id):
super().__init__(message)
self.action, self.status = action, status
self.problem, self.request_id = problem, request_id
def backoff(retry):
# 0.5 s, 1 s, 2 s... with jitter, capped at 30 s.
return min(30, 0.5 * 2**retry) * (0.5 + random.random() / 2)
# Query POSTs do not declare Idempotency-Key, so they never get one.
QUERY_POSTS = {
"/api/v1/availability/check",
"/api/v1/staff/availability",
"/api/v1/vehicles/availability",
"/api/v1/contacts/lookup",
"/api/v1/ai/ask",
}
def filmo_request(
method, path, query=None, body=None, idempotency_key=_UNSET, if_match=None, language=None, extra_headers=None
):
is_write = method != "GET" and path.split("?")[0] not in QUERY_POSTS
# Every write that declares the header carries a key. Pass idempotency_key=None to send none on purpose.
if not is_write:
key = None
else:
key = str(uuid.uuid4()) if idempotency_key is _UNSET else idempotency_key
headers = {
"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}",
"Accept": "application/json",
}
if key:
headers["Idempotency-Key"] = key # the same key on every retry of this call
if if_match:
headers["If-Match"] = if_match # the ETag you read
if language:
headers["Accept-Language"] = language # language of error messages, e.g. "en"
headers.update(extra_headers or {})
retries = waits = 0
while True:
try:
res = requests.request(
method, BASE + path, params=query, json=body, headers=headers, timeout=30
)
except requests.RequestException:
res = None # Network failure: the request may or may not have arrived.
status = res.status_code if res is not None else 0
if res is not None and status < 400:
return status, res.headers, (res.json() if res.content else None)
problem = None
if res is not None and "application/problem+json" in res.headers.get("Content-Type", ""):
try:
problem = res.json()
except ValueError:
pass
def fail(action):
label = "network error" if status == 0 else str(status)
if problem:
label += " " + problem["code"]
request_id = (res.headers.get("X-Request-Id") if res is not None else None) or (
problem or {}
).get("requestId")
return FilmoError(method + " " + path + " -> " + label, action, status, problem, request_id)
if status == 429:
# Wait as long as the server says, then retry with the same key.
if waits >= MAX_RATE_LIMIT_WAITS:
raise fail("give-up")
waits += 1
try:
seconds = float(res.headers.get("Retry-After", ""))
except ValueError:
seconds = 0
time.sleep(seconds if seconds > 0 else 1)
continue
if status == 409 and problem and problem["code"] == "conflict":
# errors[0].code says why. in_progress and lock_timeout clear by waiting;
# invalid_state (the state of the resource forbids the call) never does.
cause = ((problem.get("errors") or [{}])[0]).get("code")
if cause == "invalid_state" or retries >= MAX_RETRIES:
raise fail("read-resource")
time.sleep(backoff(retries))
retries += 1
continue
# A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
platform = status == 0 or (problem is None and status in (502, 503, 504))
if status == 500 or platform:
# The failure happened after the commit and is stored for 24 h: retrying returns it again.
if res is not None and res.headers.get("Idempotent-Replay") == "true":
raise fail("read-resource")
# Without a key a retry could write twice.
if is_write and not key:
raise fail("read-resource")
if retries >= MAX_RETRIES:
raise fail("give-up")
time.sleep(backoff(retries))
retries += 1
continue
if status == 412:
raise fail("reread-and-redo")
raise fail("give-up" if status >= 500 else "fix-request") # validation, 413, 422, 503 service_unavailable...
def return_lines(items, rounds=3):
"""207 (returnOrderLineItems with atomic False): resend only the lines that failed,
each round with a NEW key, because the same key would replay the same 207."""
pending, returned = items, []
for _ in range(rounds):
if not pending:
break
_, _, res = filmo_request(
"POST",
"/api/v1/orders/line-items/returns",
body={"items": pending, "atomic": False},
idempotency_key=str(uuid.uuid4()),
)
failed = []
for result in res["data"]:
if result["status"] == "returned":
returned.append(result["id"])
else:
failed.append(pending[result["index"]])
pending = failed
return returned, pendingaction (exit code in bash) | Meaning |
|---|---|
fix-request (10) | The request is wrong. Correct it; do not retry as is. |
read-resource (11) | The outcome is unknown or stored. Read the resource to see where it stands before you do anything else. |
reread-and-redo (12) | 412: read the resource again and redo the change with the new ETag. |
give-up (13) | The helper ran out of retries, or the failure will not clear by retrying. |
To resume a write after a restart, store the key you generated and pass it again: a new key would create a second resource. The 207 case applies only to POST /api sent with atomic: false; returnLines (return_lines in bash and Python) resends the failed lines with a new key on each round.
Errors over MCP
Link to this section: Errors over MCPAn MCP tool reports a failure as a result with isError: true, and the same RFC 9457 problem as JSON text in content[0].. There is no structuredContent on an error. Parse the text and branch on code as you do with REST. The 429 problem carries retryAfter. What REST sends as headers, a tool result carries in meta: meta., meta. and meta. (limit, remaining, reset).
Get help
Link to this section: Get helpWhen you contact support, include the requestId of the failing response. See Support or the Help Center article Contact support.
Related pages
Link to this section: Related pagesGuides
Idempotency and safe retries
Send an
Idempotency-Keyheader with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.Guides
Rate limits
Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with
Retry-After.Guides
Concurrency and optimistic locking
Every detail read returns an
ETag. Send it back in theIf-Matchheader of a write and Filmo rejects the write with 412 if the record changed since you read it, so two integrations cannot silently overwrite each other.Guides
Going live checklist
Go through these eight checks before your integration runs against production data. Each one points to the guide that explains it.
Was this page helpful?