Errores, idempotencia y paginación
Errores tipados, reintentos seguros con idempotencia, y cómo paginar.
Tres cosas que hacen robusta cualquier integración: entender los errores, reintentar sin duplicar, y recorrer listas grandes.
La forma de una respuesta
Toda respuesta correcta de la API viaja dentro de un sobre. No llega el recurso pelado: llega envuelto, y el recurso está en data.
{
"success": true,
"data": { "object": "run", "id": "run_01HXYZ" }
}
Un error usa el mismo sobre con el signo cambiado, y por eso success es el primer campo que conviene mirar:
{
"success": false,
"error": { "type": "invalid_request_error", "code": "missing_scope", "message": "…" },
"request_id": "req_01J8XYZ"
}
Con los SDK oficiales no tienes que pensar en esto: te entregan el contenido de data y convierten el sobre de error en una excepción. El sobre te importa si llamas con curl, si escribes tu propio cliente, o si generas uno del contrato —en cuyo caso ya viene declarado.
Una excepción que conviene conocer: las listas paginadas ponen pagination afuera de data, como hermana y no como hija.
{
"success": true,
"data": [ { "id": 9812 }, { "id": 9813 } ],
"pagination": { "page": 1, "limit": 50, "total": 128, "pages": 3 }
}
Errores tipados
Todos los errores de la API traen un cuerpo con type, code, message y un request_id (envíalo al soporte si algo falla). El SDK los mapea a clases:
import { MetRateLimitError, MetAuthError, MetInvalidRequestError } from '@meteor.ia/sdk';
try {
await met.runs.create({ input: 'x' });
} catch (err) {
if (err instanceof MetRateLimitError) {
console.log(`Reintentar en ${err.retryAfter}s`);
} else if (err instanceof MetAuthError) {
console.log('Key inválida o revocada');
} else if (err instanceof MetInvalidRequestError) {
console.log(err.code, err.message);
} else throw err;
}from meteor_ia import MetRateLimitError, MetAuthError, MetInvalidRequestError
try:
met.runs.create("x")
except MetRateLimitError as e:
print(f"Reintentar en {e.retry_after}s")
except MetAuthError:
print("Key inválida o revocada")
except MetInvalidRequestError as e:
print(e.code, e.message)La taxonomía es un conjunto cerrado:
type | Clase | HTTP |
|---|---|---|
authentication_error | MetAuthError | 401 |
invalid_request_error | MetInvalidRequestError | 400 |
rate_limit_error | MetRateLimitError | 429 |
agent_error | MetAgentError | 5xx del Met |
api_error | MetApiError | otros |
Cuenta bloqueada o sin Energía
Son dos errores distintos y se resuelven distinto:
- 403
workspace_blocked: la cuenta está cortada (plan inactivo o vencido, prueba terminada, tope de contactos…). Responde así toda la API, lecturas incluidas, y elmessagedice el motivo. Lo resuelve el dueño de la cuenta regularizando el plan. - 402
energy_depleted: el workspace se quedó sin Energía. Solo lo devuelven las operaciones que la consumen —ejecutar un Met (POST /runs), generar imágenes o video, analizar o enriquecer un contacto—; las lecturas y el resto de la API siguen respondiendo normal. Se resuelve recargando Energía: reintentar antes no cambia nada. El error trae enerror.recharge_urlla página del panel donde se recarga (Facturación), para que se la pases a quien administra la cuenta.
Para enterarte del segundo antes de chocarlo, GET /billing/access (scope billing:read) responde blocked: false con subscription_status: "energy_depleted".
Cuando algo falla: mira tus propias requests
Cada error trae un request_id, y ese id es buscable. No tienes que adivinar qué pasó ni abrir un ticket para saberlo.
try {
await met.contacts.create({ name: 'Ada' });
} catch (err) {
console.error(err.requestId); // req_01J8… ← guarda esto
}try:
met.contacts.create(name="Ada")
except Exception as e:
print(e.request_id) # req_01J8… ← guarda estocurl -i -X POST https://api.met.meteor.com.co/api/v1/contacts \
-H "Authorization: Bearer $MET_API_KEY" \
-H "Content-Type: application/json" -d '{}'
# El id viene en el cuerpo y en el header X-Request-IdCon ese id, entra a tu Workbench en Ajustes → Desarrolladores → Actividad y tienes:
| pestaña | qué te dice |
|---|---|
| Registros | cada request de tu workspace, filtrable por key, por clase de status (2xx/4xx/5xx) y por request_id. Cada fila se expande y muestra los bodies — los de error incluyen el detalle que causó el 400 |
| Resumen | volumen, distribución de errores y latencia p95, por 24 h / 7 d / 30 d |
| Salud | errores recientes agrupados por código, más las alertas de cuota por key (80% / 100%) |
| Webhooks | entregas salientes y el log de intentos por endpoint |
Es el camino corto: pegas el request_id en Registros y ves exactamente qué mandaste y qué contestamos. Si igual necesitas escribirnos, mándanos ese id — es lo primero que vamos a pedir.
El Workbench se sirve con tu sesión del panel, nunca por API key, y solo muestra el tráfico de tu propio workspace.
Reintentos e idempotencia
El SDK reintenta solo los 429 y 5xx con backoff exponencial (respeta Retry-After). No tienes que reimplementarlo.
Para que reintentar un POST no duplique (por ejemplo, crear dos runs por un corte de red), el SDK manda una Idempotency-Key automática en cada POST con efectos. Si el request se reenvía con la misma key, la API devuelve el mismo resultado sin volver a ejecutar.
// Ambas llamadas comparten idempotencia automática; un retry no crea dos runs.
await met.runs.create({ input: 'x' });
Con curl, manda tú el header:
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000"
Paginación
Hay dos formas de paginar en la API, y conviene saber cuál te toca — no porque cambie mucho el código, sino porque usar la equivocada no da error.
Por cursor
Runs, contactos e ítems de una colección devuelven { data, has_more } con un cursor opaco. El SDK lo recorre por ti:
for await (const run of met.runs.iterate()) {
// ...cada run, todas las páginas
}for run in met.runs.iterate():
...Si prefieres controlar el cursor a mano, list() acepta limit y starting_after:
const page = await met.runs.list({ limit: 20 });
if (page.has_more) {
const next = await met.runs.list({ limit: 20, starting_after: page.data.at(-1).id });
}
Es el modo estable: aunque entren registros nuevos mientras recorres, no se te duplica ni se te salta nada.
Por desplazamiento
Facturación, Mediateca, eventos, entregas de webhook y la búsqueda de ítems paginan con limit + offset (la búsqueda, con limit + page). Cada uno tiene su iterador:
for await (const e of met.billing.iterateExecutions()) { /* ... */ }
for await (const a of met.assets.iterate({ mime_prefix: 'image/' })) { /* ... */ }
for await (const it of met.items.iterateSearch('factura')) { /* ... */ }for e in met.billing.iterate_executions():
...
for a in met.assets.iterate(mime_prefix="image/"):
...Lo que hay que saber: mandarle starting_after a uno de estos endpoints no falla. El parámetro se ignora, responde 200 y devuelves la primera página una y otra vez — un bucle infinito que parece funcionar. Si escribes la paginación a mano, revisa en la referencia qué parámetros acepta el endpoint.
Y el desplazamiento no es estable: si algo entra o sale del conjunto mientras recorres, una fila puede aparecer dos veces o ninguna. Es una limitación del endpoint. Para un barrido exacto sobre datos que se mueven, el que sirve es el de cursor.
Listas que crecen solas: paginate=true
Tareas, actividad, comentarios, conversaciones y sus mensajes, difusiones, notas, archivos, vínculos, recordatorios y facturas devuelven por defecto un array, y se cortan en un tope fijo (100 tareas, 50 conversaciones, 200 notas…). Esa respuesta no cambia: si tu integración ya la lee, sigue igual.
Para recorrerlas enteras, pide la otra forma con paginate=true. Llega { data, has_more, next_cursor }; para la página siguiente, manda next_cursor tal cual en starting_after, hasta que has_more sea false:
curl "https://api.met.meteor.com.co/api/v1/workspaces/7/tasks?paginate=true&limit=100" \
-H "Authorization: Bearer $MET_API_KEY"
# → { "data": [...], "has_more": true, "next_cursor": "eyJvIjoxMDB9" }
curl "https://api.met.meteor.com.co/api/v1/workspaces/7/tasks?paginate=true&limit=100&starting_after=eyJvIjoxMDB9" \
-H "Authorization: Bearer $MET_API_KEY"
limit va de 1 a 100 y, si no lo mandas, son 20. El cursor es opaco: no lo armes tú ni lo guardes para otro endpoint; uno que no se entiende responde 400 validation_error con param: "starting_after". Mandar starting_after sin paginate también te da la forma nueva, salvo en GET /billing/invoices: ahí starting_after ya existía con el id de Stripe y, sin paginate=true, sigue devolviendo el array de siempre.
El SDK lo hace por ti con un iterador por lista:
for await (const tarea of met.tasks.iterate({ limit: 100 })) { /* ... */ }
for await (const m of met.conversations.iterateMessages(42)) { /* ... */ }for tarea in met.tasks.iterate(limit=100):
...
for nota in met.contacts.iterate_notes(42):
...Las conversaciones, sus mensajes, los posibles duplicados de un contacto y las facturas avanzan por id: un registro nuevo no te corre las páginas. Las demás listas avanzan por desplazamiento y tienen la misma limitación que la sección anterior. La lista completa de endpoints está en el changelog.