# 16 · Errores de la API

Este es el contrato de error de `/v1`. **No es el mismo vocabulario que el de su servidor de
herramientas**: los seis valores que el agente interpreta están en la [página 06](./06-errors.md) y
significan «qué hace el agente». Aquí se habla de transporte, y los dos vocabularios no se mezclan a
propósito — por eso el «no existe» de esta superficie se llama `resource_not_found` y no `NOT_FOUND`.

## La forma, siempre igual

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "El saldo de créditos de su cuenta no cubre esta operación.",
    "request_id": "req_22398948bdff3f425bdeb7da",
    "doc_url": "https://asixto.com/docs/gateway/errors#insufficient_credits"
  }
}
```

**Programe contra `code`.** Es lo único estable: el estado HTTP significa cosas distintas según el cliente
que use, el texto se reescribe y el idioma se traduce. Renombrar un código sería un cambio incompatible y
pasa por la política de la [página 09](./09-changelog.md).

`request_id` es lo que hay que citar en cualquier incidente. `doc_url` apunta a la explicación del código
concreto.

## Dos reglas que puede dar por buenas

**Un fallo de negocio nunca llega como `5xx`.** Si recibe un `5xx`, el problema es nuestro: o un defecto de
programación, o un servicio del que dependemos. No hay ningún caso en el que su saldo, su cupo, su
credencial o su cuerpo produzcan un `5xx`.

**Los errores temporales traen `Retry-After` en segundos.** Si no lo trae, esperar no cambia nada: hay que
arreglar algo.

## Los treinta y cuatro códigos, y qué hacer con cada uno

Son **34**, agrupados por quién lo arregla. La última columna no es «¿reintentar?»: es la acción concreta, que
es lo único que su código necesita decidir al recibirlo. Cuando la acción es esperar, el plazo llega en
`Retry-After` y no hay que calcularlo.

## Credencial y permisos

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `authentication_required` | 401 | Falta la credencial o no viene como `Authorization: Bearer …` | Mande la cabecera con esa forma exacta. Reintentar sin cambiarla no sirve |
| `invalid_api_key` | 401 | La clave no existe, está revocada o caducó | Compruebe en la consola que la clave sigue viva; si no, emita otra. No reintente |
| `ip_not_allowed` | 403 | El origen de la petición no está en la lista de su clave | Añada ese origen a la lista de la clave, o llame desde uno que ya esté |
| `insufficient_scope` | 403 | Su clave no tiene el permiso de esta operación | Lea `WWW-Authenticate`: dice cuál faltaba. Emita una clave que lo lleve ([página 15](./15-api-reference.md)) |
| `gateway_not_enabled` | 403 | Su cuenta no tiene el módulo, no tiene cupo en ese entorno o su suscripción está cortada por impago (en los dos entornos: `test` llega al operador telefónico) | Pídanos el alta de **ese** entorno: el cupo es por entorno ([página 18](./18-api-limits.md)); si es el pago, regularícelo en la consola. La preparación de la consola lo muestra en «Cuenta habilitada». No reintente |
| `permission_denied` | 403 | Prohibido por una regla que no es de permiso ni de origen | Escálelo con el `request_id`: no lo arregla ni la clave ni el origen |

Los tres motivos de `invalid_api_key` **no se distinguen a propósito**: decir cuál de los tres es ayudaría a
quien está probando claves. Un `insufficient_scope` declara en `WWW-Authenticate` qué permiso hacía falta.

## Pago y cupo

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `insufficient_credits` | 402 | Su saldo no cubre la operación, o el excedente está encendido y su cuenta no tiene medio de pago verificado | Recargue créditos, o agregue un medio de pago para que el excedente cuente, y reintente. Subir el tope de gasto no lo arregla |
| `spending_cap_reached` | 402 | Su tope de gasto por excedente no admite este importe | Suba el tope de gasto de la cuenta. Recargar **no** lo arregla |
| `period_quota_exhausted` | 429 | El techo de créditos del periodo está agotado | Espere lo que dice `Retry-After`, o pídanos un techo mayor para el periodo |
| `concurrency_limit_reached` | 429 | Su techo de peticiones simultáneas está lleno | Espere `Retry-After` y baje sus peticiones en vuelo |
| `call_concurrency_limit_reached` | 429 | Su techo de **llamadas** simultáneas está lleno | Espere a que cuelgue una llamada. Espaciar peticiones no libera este techo |
| `request_exceeds_period_quota` | 409 | La petición mínima de la operación no cabe en el techo del periodo **entero** | Pídanos un techo mayor para el periodo. Esperar no lo arregla: no trae `Retry-After` |

`request_exceeds_period_quota` no es `period_quota_exhausted`: aquí el techo no se ha gastado, es que esta
operación no cabe en él ni con el periodo vacío. Por eso es `409` y no `429`, y no trae plazo de espera. Los
dos solo pueden llegar si su cuenta tiene un techo de créditos por periodo: una cuenta de la beta nace **sin
techo** ([página 18](./18-api-limits.md)), y entonces lo que manda es el saldo.

🔴 **El catálogo tiene tres `402`, y los dos de esta tabla piden acciones opuestas: por eso son dos códigos.**
El tercero, `phone_number_suspended`, es del número propio y está en la tabla siguiente. Con `insufficient_credits` hay que
recargar; con `spending_cap_reached`, **recargar no sirve** —el consumo por excedente sigue bloqueado en el
mismo punto— y lo que hay que subir es el límite. Un cliente que solo mire el estado HTTP no distinguirá los
dos; el que mire el `code`, sí. Ninguno de los dos lleva `Retry-After`: no es un plazo que se cumpla solo.

**Los dos techos de concurrencia también son recursos distintos.** El de llamadas puede estar lleno mientras
el de peticiones está vacío, y la respuesta correcta es opuesta: espaciar peticiones, o esperar a que
cuelgue una llamada.

🔴 **Una clave `ak_test_` también recibe los dos `402` de saldo y de tope.** El entorno de pruebas ejecuta de verdad contra los
mismos proveedores, así que **también consume créditos** y también depende de su saldo y de su tope de gasto
([página 14](./14-api-quickstart.md)). Pruebas y producción gastan del **mismo** saldo y ninguno tiene un techo
de créditos propio: lo que acota un bucle de pruebas es su tope de gasto, no un cupo del entorno.

## Destino de prueba y número propio

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `test_destination_not_verified` | 409 | Con una clave `ak_test_`, el destino no es uno de sus números de prueba verificados | Registre y verifique ese número como destino de prueba en la consola, o llame a uno que ya lo esté. No se le cobra |
| `phone_number_required` | 409 | En `live`, su cuenta no tiene número propio, o su expediente falta, está incompleto o fue rechazado | Compre su número, o cree, complete o corrija el expediente en la consola. Reintentar no cambia nada |
| `phone_number_in_review` | 409 | En `live`, su número o su expediente está en provisión o en revisión | No reintente en bucle: consulte el estado del número en la consola. Cuando esté aprobado, la misma petición funciona. No trae `Retry-After`: no es un plazo que podamos prometer |
| `phone_number_suspended` | 402 | En `live`, su número está suspendido porque venció la gracia del pago de su cuota | Póngase al día con el pago de la cuenta; el número vuelve a servicio sin cambiar de clave |

Ninguno es `403`: su credencial sirve en los cuatro. Los tres `409` dicen que lo que no está listo es el
destino o el número, y `phone_number_suspended` es `402` como el saldo porque lo arregla un pago.

Los cuatro salen **antes** de abrir el trabajo: no se cobran y no consumen su `Idempotency-Key`, así que,
arreglada la causa, reintente con la **misma** clave. En `live` ya no hay número de Asixto de reserva: sin
número propio listo, la llamada no sale ([página 09](./09-changelog.md)). Qué poner en `to` y cómo dar de
alta un número de prueba, en la [página 14](./14-api-quickstart.md).

## Su petición

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `validation_error` | 400 | El cuerpo no cumple el contrato. El mensaje dice qué campo | Corrija ese campo. Reintentar el mismo cuerpo da el mismo error |
| `payload_too_large` | 413 | El cuerpo o el archivo supera el techo | Recorte o parta el contenido bajo el techo de la operación ([página 15](./15-api-reference.md)) |
| `unsupported_media_type` | 415 | El tipo o el formato del archivo no se admite | Convierta el archivo a uno de los formatos que lista el mensaje |
| `document_unreachable` | 422 | No pudimos descargar el documento que declaró | Con `Retry-After`, espere y reintente; sin él, arregle el enlace (URL firmada caducada, redirecciones) |
| `resource_not_found` | 404 | La ruta o el trabajo no existen | Revise la ruta y el `job_id`. Un trabajo de otra cuenta responde igual |
| `method_not_allowed` | 405 | La ruta existe pero no con ese método | Use el verbo que publica la referencia. Hoy **no tiene emisor**: no programe contra él |
| `rate_limited` | 429 | Techo de tasa de la plataforma, no su cupo | Espere `Retry-After`. No hay saldo que leer: el cupo es compartido |
| `key_rate_limited` | 429 | Ritmo de **su clave**; el saldo viaja en `RateLimit` | Espere `Retry-After` y regúlese con `RateLimit` ([página 18](./18-api-limits.md)) |
| `request_rejected` | 400 | Rechazo sin causa más fina | Revise la petición; si no encuentra la causa, escálela con el `request_id` |

`document_unreachable` es un `422` y no un `400` ni un `503` porque el fallo es del recurso, no de su
petición ni de nuestro servicio: su URL firmada caducó, su almacenamiento está limitando la tasa o la cadena
de redirecciones no termina. Cuando la causa es transitoria viaja con `Retry-After`, y eso es lo que
distingue «reintente» de «arregle el enlace».

`rate_limited` no es su cupo: es el limitador de la plataforma, y aquí no hay un cupo propio que se libere.
`key_rate_limited` sí lo es —el ritmo de su clave— y su saldo viaja en `RateLimit` ([página 18](./18-api-limits.md)).
Los dos traen `Retry-After`, que es lo que hace el reintento correcto sin mirar nada más.

## Estado del trabajo, de la clave de idempotencia y del recurso

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `request_in_flight` | 409 | Otra petición con la misma clave sigue en curso | Consulte el trabajo ([página 17](./17-jobs.md)) en vez de reintentar en bucle |
| `idempotency_key_reused` | 409 | Reusó la clave para otra operación, o para la misma con otras `variables` | Use una clave distinta por operación. Nunca la comparta entre dos |
| `result_not_replayable` | 409 | La operación se ejecutó y su resultado no se puede reenviar | Pida la operación con una clave **nueva**. El cobro anterior no se duplica |
| `job_not_completed` | 409 | El trabajo de esa clave venció su plazo sin cerrarse | Pida la operación con una clave **nueva**: no hay resultado que recuperar |
| `agent_outside_schedule` | 409 | El instante de la petición cae fuera del horario del agente | Reintente a partir de `next_window_at`, que viene en el cuerpo del error |

Los cinco son `409` porque el conflicto es con el **estado** del recurso, no con su autorización ni con su
cuerpo. `request_in_flight` sigue el precedente de la industria para una clave en curso.

`agent_outside_schedule` es el único código cuyo cuerpo trae un **quinto campo**, `next_window_at`, con la
apertura de la ventana siguiente en ISO-8601 UTC. Lleva una fecha y no un `Retry-After` porque aquí el plazo
se conoce: lo declara el horario del propio agente, no una avería. Los otros treinta y tres **no** traen el
campo, y la clave se omite en vez de viajar en `null`, así que su capa de transporte no tiene que preverlo.

`result_not_replayable` no es un `404`: el trabajo **existe** y su cobro también; lo que no existe es una
copia de su salida. Hoy solo lo produce la síntesis de voz ([página 15](./15-api-reference.md)).

## Nuestro lado

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `upstream_unavailable` | 503 | Un servicio del que dependemos no responde | Espere `Retry-After` y reintente; con qué clave depende de la superficie (debajo). No se le cobra |
| `upstream_timeout` | 504 | Aceptó la petición y no respondió a tiempo | La operación pudo ejecutarse. En `POST /v1/calls`, reintente con la **misma** clave; en el resto, con una **nueva** (debajo). En una operación síncrona no se le cobra; en `POST /v1/calls` se cobra solo si nos llega el cierre de la llamada (debajo) |
| `operation_not_priced` | 500 | Defecto nuestro: la operación no tiene precio | Escálelo con el `request_id`. No reintente: no se arregla solo |
| `internal_error` | 500 | Defecto nuestro: excepción no prevista | Escálelo con el `request_id` |

**Un `upstream_unavailable` nunca se cobra, y un `upstream_timeout` tampoco, salvo en `POST /v1/calls`:**
ahí un `504` se cobra si nos llega el cierre de la llamada —la liquida, como a cualquier llamada—; si el trabajo
vence sin él, se le devuelve lo reservado. Un `upstream_unavailable` también es lo que recibe cuando el proveedor
responde pero no entrega lo que se pidió —un análisis degradado, una transcripción descartada, un turno que
solo alcanza a disculparse—: para usted es el mismo hecho, no recibió lo que pidió y no se le cobra
([página 13](./13-data.md)).

**Con qué clave reintentar un `5xx` transitorio depende de dónde lo recibió:**

| Superficie | Reintente con | Por qué |
|---|---|---|
| `POST /v1/calls`, con `503` o con `504` | La **misma** clave, **siempre** | Ver debajo: una clave nueva tras un `504` puede llamar dos veces a la misma persona |
| Una operación síncrona, cuando el `503` es al comprobar su cupo de peticiones simultáneas | La misma o una nueva: **cualquiera** vale | Sale antes de abrir el trabajo, así que la clave no quedó usada |
| Una operación síncrona en cualquier otro caso, y el turno del agente | Una clave **nueva** | El trabajo se cierra con el fallo, y repetir la clave le devuelve el mismo error sin volver a intentarlo ([página 17](./17-jobs.md)) |

Si repite una clave cuyo trabajo ya terminó con un `5xx` transitorio, recibe el mismo código con un mensaje que
le pide una **clave nueva** y sin `Retry-After`: esa clave republica su fallo en cada intento.

**En `POST /v1/calls` el código le dice si la llamada salió:**

- **`503 upstream_unavailable`: la llamada no se originó.** No hubo marcado, se le devuelve lo reservado y la
  clave vuelve a estar libre: con la misma clave, la llamada se intenta de nuevo.
- **`504 upstream_timeout`: la llamada pudo originarse.** No sabemos si el teléfono sonó, así que el trabajo
  sigue abierto y no se le devuelve nada todavía. Con la misma clave recibe `409 request_in_flight` mientras se
  resuelve. Si nos llega el cierre de la llamada, la clave le devuelve su resultado y se cobra. Si el trabajo
  vence sin él —no salió, o salió y su cierre se perdió—, se le devuelve lo reservado y la clave responde
  `409 job_not_completed`: antes de pedirla con una clave nueva, confirme que la persona no recibió la llamada.
  Con una clave nueva mientras tanto **podría llamar dos veces a la misma persona**.

Fuera de las llamadas, `upstream_timeout` también significa que la operación **puede haberse ejecutado** y
nosotros no tenemos su resultado. En una operación síncrona no se le cobra, y el coste de ese intento lo
absorbemos nosotros.

Un `500` es lo único que conviene escalar: significa que hay un defecto en nuestro código, no en su
integración. Cítenos el `request_id`.

## Cómo escribir el manejo de errores

1. **`2xx`** → siga. Apunte `usage` si va a repercutir el coste.
2. **`5xx` con `Retry-After`** → espere lo que dice y reintente. En `POST /v1/calls`, con la misma clave;
   en el resto, con una nueva.
3. **`429`** → espere lo que dice `Retry-After`. Si es `call_concurrency_limit_reached`, lo que tiene que
   terminar es una llamada, no una petición.
4. **`402`** → pare y mire el `code`: o recarga, o sube su tope, o paga la cuota de su número.
5. **`409 request_in_flight`** → su operación anterior sigue viva. Consulte el trabajo
   ([página 17](./17-jobs.md)) en vez de reintentar en bucle.
6. **`409 agent_outside_schedule`** → agende el reintento con `next_window_at` del cuerpo. Es el único `409`
   que le dice cuándo volver.
7. **El resto de `4xx`** → es su petición o su credencial. Reintentar no cambia nada.

Lo que no conviene: reintentar un `4xx` sin cambiar nada, o reintentar sin respetar `Retry-After`.
