Guías
Errores
Los errores son documentos de problema RFC 9457. Ramifica según el code estable, nunca según el texto, y usa la tabla de reintentos para decidir si volver a intentarlo.
El documento de problema
Enlace a esta sección: El documento de problemaUn error de la API tiene el tipo de contenido application. code es estable y está pensado para el control de flujo; type es el mismo código como URI, https://, que lleva a su página de documentación, y title es su nombre corto en inglés. Para un código dado, type, title y code nunca cambian. Ramifica según code, no según la 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"
}| Propiedad | Significado |
|---|---|
type, title | El código como URI, y su nombre corto en inglés. |
status | El estado HTTP de la respuesta. |
code | El código de error estable. Mapéalo en tu cliente. |
detail | Una explicación de esta ocurrencia, para personas. No la uses para el control de flujo. |
requestId | El identificador de la petición; también la cabecera X-Request-Id. Cítalo cuando contactes con soporte. |
errors | En validation_error, cada campo incorrecto; en contact_in_use, address_in_use y insufficient_stock, lo que bloquea la operación; en conflict, la causa en errors[0].. |
requiredScope | En insufficient_scope: el permiso (scope) que necesita la clave. |
requiredAddon | En addon_required: el add-on que necesita el módulo. |
retryAfter | En rate_limited: los segundos que hay que esperar antes de reintentar, el mismo valor que la cabecera Retry-After. |
Índice de códigos de error
Enlace a esta sección: Índice de códigos de error| Estado | Código | Significado |
|---|---|---|
| 401 | unauthorized | La petición no lleva ninguna credencial Authorization: Bearer. |
| 401 | api_key_invalid | La clave no se reconoce. |
| 401 | api_key_revoked | La clave fue revocada. |
| 401 | api_key_expired | La clave ha caducado. |
| 401 | api_key_owner_inactive | El administrador propietario de la clave salió de la cuenta o fue desactivado. |
| 401 | api_key_owner_not_admin | El propietario de la clave ya no es administrador. |
| 403 | insufficient_scope | La clave no tiene el scope que exige la operación. |
| 403 | forbidden | El propietario de la clave carece del módulo, la organización o el permiso de administrador que exige la operación. |
| 402 | addon_required | La cuenta no ha contratado, ha dejado caducar o ha perdido con el fin de la prueba gratuita el add-on de ese módulo. |
| 400 | validation_error | La consulta o el cuerpo no es válido. errors[] lista cada campo erróneo. |
| 422 | idempotency_key_reused | La Idempotency-Key ya se usó con un cuerpo distinto. |
| 404 | not_found | El recurso no existe o queda fuera del aislamiento de la clave. No se pueden distinguir. |
| 409 | conflict | Una petición con esa clave sigue en curso (in_progress), una escritura condicional retuvo el recurso más de 5 segundos (lock_timeout), o el estado del recurso impide la operación (invalid_state). La causa está en errors[0].. |
| 409 | contact_in_use | Otros registros siguen usando el contacto; errors[] los cuenta por tipo. |
| 409 | address_in_use | Los pedidos siguen usando la dirección como dirección de facturación o de envío. |
| 409 | insufficient_stock | No hay stock suficiente para la petición; errors[] tiene una entrada por cada artículo que falta. |
| 412 | precondition_failed | La etiqueta If-Match ya no coincide: el recurso cambió desde que lo leíste. |
| 429 | rate_limited | Se superó un límite de peticiones; Retry-After, repetido en retryAfter, indica cuánto esperar. |
| 500 | internal_error | Un error interno. También es la respuesta si falla el almacén que cuenta los límites de peticiones: la API no se salta el límite en ese caso, así que reintenta con backoff. |
| 503 | service_unavailable | La función está desactivada para la cuenta. Reintentar no ayuda hasta que se active. |
Los códigos 401 se comprueban en un orden fijo: sin credencial bearer (unauthorized), después una clave no reconocida (api_key_invalid), luego una revocada, una caducada y, por último, un propietario inactivo o que ya no es administrador. Un 401 y un 403 insufficient_scope no consumen cuota del límite de peticiones; el resto de respuestas de una clave autenticada sí.
Cada código tiene su propia página con las operaciones que lo devuelven y qué hacer. Los códigos de autenticación también se tratan en Autenticación.
Tres tipos de «no puedes»
Enlace a esta sección: Tres tipos de «no puedes»Tres rechazos distintos se parecen y se arreglan en tres sitios distintos. Mandarlos todos al mismo sitio te lleva a depurar en el equivocado.
| Estado y código | Trata de | Qué falla | Quién lo arregla |
|---|---|---|---|
403 insufficient_scope | La clave | La clave no tiene el scope que exige el endpoint; requiredScope lo nombra. | Vuelve a emitir la clave con ese permiso (scope). |
403 forbidden | El propietario de la clave | La clave es correcta, pero su propietario carece del módulo, la organización o el permiso de administrador que exige la operación. | Un administrador de la cuenta concede el permiso. |
402 addon_required | El add-on | La cuenta no ha contratado, o ha dejado caducar, el add-on del módulo; requiredAddon lo nombra. Ningún rol lo evita. La API y el MCP no son un add-on. | Quien gestione la suscripción. |
Artículos del Centro de ayuda: Scopes y permisos de la API, Roles y permisos y Tu suscripción. Consulta también Permisos (scopes).
Detalles de los errores que llevan más
Enlace a esta sección: Detalles de los errores que llevan másvalidation_error
Enlace a esta sección: validation_errorerrors[] lista todos los campos incorrectos, no solo el primero. path usa notación de puntos y corchetes (lines[0].) y está vacío cuando el problema es todo el cuerpo. El campo code es estable. Estos son los códigos vistos hasta ahora; la lista no es exhaustiva, así que trata uno desconocido como un campo no válido genérico:
required,must_not_be_nullinvalid_type,invalid_value,invalid_formattoo_small,too_bigunknown_fieldnot_foundmust_be_after
Un cuerpo que hace referencia a un identificador fuera de tu aislamiento, como el contacto de un pedido, es un 400 que nombra el campo, y no se escribe nada. El mensaje es el mismo tanto si el identificador no existe como si pertenece a otra cuenta, así que no sirve para sondear ninguno de los dos casos. Leer un recurso fuera de tu aislamiento es un 404 idéntico a que no exista.
conflict
Enlace a esta sección: conflictUn 409 conflict indica el motivo en errors[0].. La causa decide si esperar sirve de algo:
errors[0]. | Causa | Qué hacer |
|---|---|---|
in_progress | Otra petición con el mismo Idempotency-Key sigue ejecutándose. | Espera y reintenta con la misma clave. No envíes un segundo intento en paralelo. |
lock_timeout | Otra escritura condicional (If-Match) retuvo el recurso más de 5 segundos. | Reintenta con espera. Si sigue ocurriendo, lee el recurso. |
invalid_state | El estado del recurso prohíbe la operación, o la escritura rompió una regla de unicidad. Una línea de un lote atómico también lo notifica cuando la línea está en un estado incorrecto; una línea que falta o una cantidad no válida notifican not_found o validation_error. | No reintentes: esperar no cambia el estado. Lee el recurso y decide. |
Reintenta in_progress y lock_timeout como máximo 3 veces con espera exponencial y la misma clave, y después lee el recurso. Trata un conflicto 409 con cualquier otro errors[0]., o sin ninguno, como invalid_state tras esos 3 reintentos.
contact_in_use y address_in_use
Enlace a esta sección: contact_in_use y address_in_useerrors[] tiene una entrada por tipo de registro que usa el contacto o la dirección: path es el tipo y count cuántos registros de ese tipo lo usan. Los valores de path salen de un conjunto cerrado, que figura en el x-path-values de la propiedad path en la especificación: para contact_in_use, orders, projects, quotes, invoices, proformas, creditNotes, simpleCreditNotes, salesReceipts, supplierInvoices, contracts, subrents, purchaseOrders, sepaMandates, receipts y consignments; para address_in_use, orders. El conjunto puede crecer, así que tolera un valor que no conozcas. Cuando algo usa el contacto, no se borra nada.
insufficient_stock
Enlace a esta sección: insufficient_stockUna entrada por artículo, o por espacio, que se queda corto. path es items o spaces, y cada entrada tiene itemId, requested, available y shortfall.
Idioma de los mensajes
Enlace a esta sección: Idioma de los mensajesdetail y cada errors[]. llegan en el idioma negociado con la cabecera Accept-Language. es-ES y en-US están garantizados; cualquier otro idioma admitido recurre al inglés. Sin la cabecera, una petición hecha con una clave que existe usa el idioma preferido del propietario de la clave, también cuando el error es un 401 por una clave revocada o caducada o un 403 insufficient_scope. Una petición sin credencial, o con una clave que Filmo no reconoce, no tiene propietario al que preguntar: su 401 llega en español salvo que envíes Accept-Language. Envía siempre la cabecera. Content-Language te dice cuál usó la respuesta. No escribas nunca lógica que dependa de esos textos. Consulta Idiomas.
Respuestas que no vienen de la API
Enlace a esta sección: Respuestas que no vienen de la APIAlgunos fallos ocurren antes de que la petición llegue a la API o después de que salga de ella: una función que agota el tiempo de espera (504), un cuerpo de petición mayor de lo que acepta la plataforma (413), un bad gateway (502) o un desafío de cortafuegos. Llegan sin documento de problema, así que no hay code ni requestId. Los reconoces porque el tipo de contenido no es application. No intentes leer code en ellos: decide por el estado HTTP.
- 502 y 504: el resultado es desconocido. Reintenta con la misma
Idempotency-Key; en una escritura sin clave, lee primero el recurso. - 413: el cuerpo es demasiado grande. Reintentar no sirve; envía menos datos en cada petición.
- La plataforma genera 413, 502 y 504; la API nunca los envía como documento de problema. No dependas de ninguna cabecera específica de la plataforma para reconocer estas respuestas: cualquier 5xx sin
applicationes una de ellas./problem+json - Timeouts:
POST /apipuede tardar hasta 300 segundos, así que da a esa llamada un timeout de cliente largo. Para cualquier otra ruta, Filmo no documenta ningún límite; define tus propios timeouts de cliente y gestiona un 504./v1 /ai /ask
Decisiones de reintento
Enlace a esta sección: Decisiones de reintentoUna política por estado no basta. El mismo 409 puede significar espera y reintenta o no reintentes nunca, un 500 puede repetirse durante 24 horas, y un 207 no se arregla reenviando la misma petición. Ante un 409 conflict, lee primero la causa en errors[0]. (ver arriba). Después busca la primera fila que coincida con tu respuesta.
| Estado | Idempotency-Key | Idempotent-Replay: true | Qué hacer |
|---|---|---|---|
| 409 conflict · in_progress | Sí | No | errors[0]. es in_progress: otra petición con la misma Idempotency-Key sigue en curso. Espera y reintenta con la misma clave (espera exponencial, hasta 3 veces): recibirás su respuesta guardada cuando termine. |
| 409 conflict · lock_timeout | — | No | errors[0]. es lock_timeout: otra escritura condicional retuvo el recurso más de 5 s. Reintenta con la misma clave y espera exponencial, hasta 3 veces; si persiste, vuelve a leer el recurso. |
| 409 conflict · invalid_state | — | — | errors[0]. es invalid_state: el estado del recurso impide la operación (una violación de unicidad, una línea de un lote atómico). No reintentes: esperar no lo arregla nunca. Vuelve a leer el recurso y decide según su estado actual. |
| 409 contact_in_use · address_in_use · insufficient_stock | — | — | No reintentes. errors[] dice qué bloquea la operación: corrígelo primero. |
| 412 precondition_failed | — | — | Vuelve a leer el recurso, rehaz tu cambio sobre él y envía el ETag nuevo. |
| 422 idempotency_key_reused | Sí | — | Es un fallo del cliente: la clave se usó con otro cuerpo. Usa una clave nueva solo si de verdad es otra operación. |
| 429 rate_limited | — | — | Espera los segundos de Retry-After y reintenta con la misma clave. |
| 500 internal_error | Sí | Sí | Para. El fallo ocurrió después del commit y se guarda y se repite durante 24 h con esa clave: lee el recurso para ver dónde quedó. |
| 500 internal_error | Sí | No | Reintenta con espera exponencial y la misma clave. |
| 500 internal_error | No | — | Lee antes de reintentar una escritura: sin clave nada te protege de un duplicado. |
| 503 service_unavailable | — | — | No reintentes: la función está desactivada en la cuenta. |
207 (returnOrderLineItems con atomic: false) | Sí | — | Las líneas que fallaron no se devolvieron y el resto sí. Reenvía solo las fallidas con una clave nueva; repetir la clave devolvería el mismo 207. |
413 · 502 · 504 (no es application) | Sí | — | Respuesta de la plataforma, no de la API: decide por el estado y reintenta con la misma clave. Un 413 no se arregla reintentando (el cuerpo es demasiado grande). |
| Cualquier 4xx de validación | — | — | Corrige la petición y reenvíala; nunca en bucle. |
| 401 · 402 · 403 (clave, propietario o plan) | — | — | No reintentes: reenviar la misma petición vuelve a fallar. Actúa sobre la clave, su propietario o el plan: usa una clave válida con el permiso adecuado, pide a un administrador el permiso que falta o contrata el add-on. |
La clave es lo que hace seguros los reintentos: consulta Idempotencia. Para las escrituras que usan If-Match, consulta Concurrencia.
Ayudante de reintentos de referencia
Enlace a esta sección: Ayudante de reintentos de referenciaEste helper envuelve cualquier llamada y aplica las decisiones anteriores. Cópialo, cambia el nombre de las variables de entorno si guardas tu clave en otro sitio, y llámalo en lugar de fetch, requests o curl.
- Toda escritura que declara la cabecera
Idempotency-Keylleva una: la que pasas o un UUID nuevo. La misma clave se envía en cada reintento de esa llamada. El ayudante no envía ninguna en los POST de consulta (las comprobaciones de disponibilidad, la búsqueda de contactos yPOST /api), que no declaran la cabecera y la ignoran./v1 /ai /ask - 429: espera
Retry-Aftersegundos y reintenta, hasta 5 veces. - 409
conflict: conin_progressylock_timeout, hasta 3 reintentos con espera exponencial; después se detiene y te indica que leas el recurso; coninvalid_statese detiene de inmediato y te dice lo mismo. - 500 con
Idempotent-Replay: true: se detiene de inmediato y te indica que leas el recurso. - 500 sin esa cabecera: hasta 3 reintentos con espera exponencial y la misma clave. En una escritura enviada sin clave, se detiene y te indica que leas el recurso.
- 502, 503 y 504 sin documento de problema, y fallos de red: se tratan como un 500 sin la cabecera de repetición.
- Todo lo demás falla de inmediato, con el estado y el
code, y unaactionque dice qué hacer a continuación.
#!/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 (código de salida en bash) | Significado |
|---|---|
fix-request (10) | La petición es incorrecta. Corrígela; no la reintentes tal cual. |
read-resource (11) | El resultado es desconocido o está almacenado. Lee el recurso para ver en qué punto está antes de hacer nada más. |
reread-and-redo (12) | 412: vuelve a leer el recurso y rehaz el cambio con el nuevo ETag. |
give-up (13) | El helper agotó los reintentos, o el fallo no se resolverá reintentando. |
Para reanudar una escritura tras un reinicio, guarda la clave que generaste y pásala de nuevo: una clave nueva crearía un segundo recurso. El caso 207 solo se aplica a POST /api enviado con atomic: false; returnLines (return_lines en bash y Python) reenvía las líneas fallidas con una clave nueva en cada ronda.
Errores por MCP
Enlace a esta sección: Errores por MCPUna herramienta MCP informa de un fallo como un resultado con isError: true, y del mismo problema RFC 9457 como texto JSON en content[0].. No hay structuredContent en un error. Analiza el texto y ramifica según code como haces con REST. El problema 429 lleva retryAfter. Lo que REST envía como cabeceras, un resultado de herramienta lo lleva en meta: meta., meta. y meta. (limit, remaining, reset).
Obtener ayuda
Enlace a esta sección: Obtener ayudaCuando contactes con soporte, incluye el requestId de la respuesta que falla. Consulta Soporte o el artículo del Centro de ayuda Contactar con soporte.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasGuías
Idempotencia y reintentos seguros
Envía una cabecera
Idempotency-Keycon cada escritura y reutiliza el mismo valor cuando reintentes esa operación: Filmo devuelve el resultado almacenado del primer intento en lugar de escribir dos veces.Guías
Límites de peticiones
Cada clave de API tiene 120 peticiones por minuto, y las tres comprobaciones de disponibilidad comparten su propia cuota de 30 por minuto. Al pasar un límite, la API responde 429 con
Retry-After.Guías
Concurrencia y bloqueo optimista
Cada lectura de detalle devuelve un
ETag. Envíalo de vuelta en la cabeceraIf-Matchde una escritura y Filmo rechaza la escritura con 412 si el registro ha cambiado desde que lo leíste, de modo que dos integraciones no puedan sobrescribirse en silencio.Guías
Lista de comprobación de salida a producción
Repasa estas ocho comprobaciones antes de que tu integración funcione con datos de producción. Cada una remite a la guía que la explica.
¿Te ha resultado útil esta página?