Catálogo de Errores — Inventario y Ubicaciones
/inventory/* y /locations/* son dos servicios distintos detrás de un mismo API Gateway. No comparten un envelope de respuesta ni una convención de código de error — cada uno devuelve, literalmente, lo que su capa de servicio produjo. Esta página documenta ambos tal como se comportan hoy en producción, incluyendo sus inconsistencias.
No hay envelope {data, meta}
Cada respuesta es el cuerpo devuelto por la capa de servicio, serializado tal cual. Las formas varían por recurso: documento plano, { items, pagination }, { transfers, pagination }, { data, total, page, limit, totalPages, hasMore } (lotes), { locations, totalCount, page, limit } (ubicaciones — nota totalCount, no total). No asumas un esquema de respuesta compartido entre endpoints de este dominio.
Los dos códigos genéricos de error 500
| Base path | Código en el 500 genérico |
|---|---|
/inventory/* | internal-error |
/locations/* | internal-server-error |
Son literalmente distintos. No es un typo de esta guía — son dos lambdas separadas con implementaciones independientes del catch-all genérico.
Tenant faltante (401)
Ambos servicios devuelven 401 con el mismo código, a pesar de que el código sugiere un 400:
{ "code": "bad-request/missing-tenant", "message": "..." }Permisos y tenant suspendido (403)
Un 403 puede deberse a que tu API key no tiene el permiso requerido por el endpoint, o a que el tenant está suspendido (por ejemplo, por facturación). La fuente de esta auditoría no confirmó un código de error literal único para estos casos — verifica el message de la respuesta para distinguir la causa exacta.
Errores del dominio Inventario (/inventory/*)
Todos requieren el permiso indicado en cada endpoint (inventory:read, inventory:update, inventory:adjust, inventory:transfer, inventory:count o inventory:manage según el recurso — ver API de Inventario).
Stock (/inventory, /inventory/items, /inventory/location, /inventory/bulk-update, /inventory/stats)
| Código | HTTP | Causa | Cómo resolver |
|---|---|---|---|
bad-request/missing-sku | 400 | Falta el SKU en la ruta o en el body. | Incluye sku. |
bad-request/missing-body | 400 | El body de la petición está vacío. | Envía un body JSON válido. |
bad-request/invalid-json | 400 | El body no es JSON válido. | Verifica el Content-Type y el JSON enviado. |
bad-request/invalid-stock | 400 | stock no es de tipo number. | Envía stock como número, no como string. |
bad-request/invalid-items | 400 | POST /inventory/bulk-update — items inválido o vacío. | Envía un arreglo items no vacío con {sku, stock}. |
not-found | 404 | GET /inventory/items/{sku} o GET /inventory/location/{id}/product/{sku} — no existe inventario para ese SKU/ubicación. | Verifica el SKU y el locationId. |
not-found/location | 404 | PUT /inventory/location/{id}/product/{sku} — la ubicación no existe. | Verifica el locationId. |
forbidden/inventory-handler | 403 | PUT /inventory/location/{id}/product/{sku} — el SKU/ubicación está bajo manejo de integración/canal y no permite edición manual. | Usa el flujo de sincronización del handler correspondiente, no la edición manual. |
Stock negativo devuelve 500, no 400
Enviar un stock negativo en PUT /inventory/location/{id}/product/{sku} o dentro de POST /inventory/bulk-update produce hoy un 500 sin estructura de error de negocio:
{ "code": "internal-error", "message": "Stock cannot be negative" }No es un 400 de validación — es una excepción genérica de JavaScript (Error('Stock cannot be negative')) que cae al catch-all del lambda. Valida tú mismo que stock sea ≥ 0 antes de enviarlo; no confíes en que la API te devuelva un 400 estructurado para este caso hoy.
Reportes (/inventory/reports)
| Código | HTTP | Causa |
|---|---|---|
bad-request/missing-type | 400 | Falta reportType. |
bad-request/invalid-report | 400 | reportType no es uno de los cinco valores válidos. |
Transferencias (/inventory/transfers)
Requieren inventory:transfer.
| Código | HTTP | Causa |
|---|---|---|
bad-request/missing-origin | 400 | Falta originId al crear. |
bad-request/missing-destination | 400 | Falta destinationId al crear. |
bad-request/missing-products | 400 | Falta o está vacío productsList al crear. |
not-found | 404 | La transferencia no existe, en cualquier acción (approve, receive, cancel). |
Sin máquina de estados — y sin idempotencia
approve, receive y cancel no validan el estado actual de la transferencia antes de aplicar el cambio: una transferencia puede recibirse dos veces (aplicando el movimiento de stock dos veces) o cancelarse después de recibida. Además, receive no es idempotente — reenviar la misma petición vuelve a aplicar el movimiento de stock. No reintentes un POST .../receive a ciegas ante un timeout; verifica primero el estado de la transferencia.
Ajustes (/inventory/adjustments)
Requieren inventory:adjust.
| Código | HTTP | Causa |
|---|---|---|
bad-request/missing-location | 400 | Falta locationId al crear. |
bad-request/missing-sku | 400 | Falta sku al crear. |
bad-request/missing-quantity | 400 | Falta newQuantity al crear. |
bad-request/missing-reason | 400 | Falta reason al crear, o falta el motivo al rechazar. |
bad-request/invalid-reason | 400 | reason no está entre los 11 valores aceptados por este endpoint (ver abajo). |
not-found | 404 | El ajuste no existe. |
'sale' no es un motivo válido aquí, aunque el modelo lo soporte
POST /inventory/adjustments acepta exactamente estos 11 valores de reason: count_correction, damage, theft, expired, found, return, supplier_error, restock, shrinkage, sample, other. El schema canónico de InventoryAdjustment define un doceavo valor, sale ("venta — decremento automático por orden"), pero este endpoint público lo rechaza con bad-request/invalid-reason. sale está reservado para el decremento automático interno que dispara una orden — no lo uses al crear un ajuste manual.
Conteos (/inventory/counts)
Requieren inventory:count.
| Código | HTTP | Causa |
|---|---|---|
bad-request/missing-name | 400 | Falta name al crear. |
bad-request/missing-location | 400 | Falta locationId al crear. |
bad-request/missing-type | 400 | Falta countType al crear. |
bad-request/invalid-items | 400 | PUT /inventory/counts/{id}/items — countItems inválido. |
not-found | 404 | El conteo no existe. |
Lotes (/inventory/lots)
Requieren inventory:read. Superficie de solo lectura.
| Código | HTTP | Causa |
|---|---|---|
not-found | 404 | El lote no existe. |
| — | 400 | Falta locationId en GET /inventory/lots/sku/{sku}/valuation (obligatorio; el código literal no fue confirmado en esta auditoría). |
Handlers de inventario externo (/inventory/handlers)
Requieren inventory:manage.
Brecha de aislamiento multi-tenant en producción
GET /inventory/handlers/{id} y POST /inventory/handlers/{id}/sync no filtran por tenantId en la versión actualmente desplegada en producción. Si el handlerId de otro tenant es adivinado o se filtra, es posible leer o escribir su registro de handler/sincronización. La corrección (ambos métodos ganan un parámetro tenantId obligatorio) ya existe integrada en la rama de desarrollo, pero todavía no está desplegada a producción. Si expones handlerId en un cliente, trátalo como un secreto hasta que la corrección esté en vivo.
Errores del dominio Ubicaciones (/locations/*)
Requieren el permiso indicado en cada endpoint (locations:read, locations:create, locations:update o locations:delete — ver Ubicaciones y Zonas).
| Código | HTTP | Causa |
|---|---|---|
not-found | 404 | GET /locations/{id} — no existe la ubicación. Único endpoint del dominio con este código literal en un 404. |
bad-request/not-found | 404 | El resto de los lookups por ID (places, set-default, delete) cuando el recurso no existe. |
bad-request/not-found | 400 | GET /locations/count cuando el servicio no resuelve el conteo. |
bad-request/missing-name | 400 | Falta name al crear una ubicación. |
bad-request/missing-type | 400 | Falta type al crear una ubicación. |
bad-request/invalid-type | 400 | type no está en el enum válido (ubicación o zona). |
bad-request/can't-create | 400 | El servicio no pudo crear la ubicación. |
bad-request/can't-update | 400 | El servicio no pudo actualizar la ubicación. |
bad-request/can't-delete | 400 | El servicio no pudo eliminar la ubicación. |
bad-request/system-location | 400 | Se intentó eliminar una ubicación de sistema, o quitarle el flag isDefault. |
bad-request/missing-id | 400 | PUT /locations/{id} invocado sin el parámetro de ruta id. |
bad-request/missing-code, bad-request/missing-name, bad-request/missing-type | 400 | Faltan campos obligatorios al crear una zona (code, name, type). |
bad-request/duplicate-code | 400 | Ya existe una zona con ese code en la ubicación (verificado en memoria, no por índice único de base de datos). |
internal-server-error | 500 | Error genérico del lambda de ubicaciones. |
'not-found' inconsistente dentro del propio servicio
bad-request/not-found combina un código con prefijo bad-request/ con un status 404 — es contradictorio a nivel de nombre, pero así se comporta hoy. Rama tu lógica por el par (code, status) completo, no asumas que bad-request/* siempre implica 400.
Cuándo reintentar y cuándo no
| Status | ¿Reintentar? | Notas |
|---|---|---|
400 validación | No | Corrige la petición según el code. |
401 tenant/auth faltante | No | Revisa el header Authorization y tu API key. |
403 permiso/tenant suspendido | No | Solicita el scope correcto o resuelve el estado de facturación del tenant. |
404 no encontrado | No | El recurso no existe en tu tenant. |
500 interno (internal-error / internal-server-error) | Sí, con backoff | Frecuentemente transitorio — pero recuerda que un stock negativo también produce un 500 (ver arriba), así que un 500 no siempre es transitorio en este dominio. |
No reintentes ciegamente escrituras sin máquina de estados
POST /inventory/transfers/{id}/receive y las transiciones de conteos/transferencias/ajustes no tienen un guardia de estado ni son idempotentes. Antes de reintentar una escritura tras un timeout o un 500, consulta primero el estado actual del recurso (GET) para confirmar si la operación ya se aplicó — reintentar a ciegas puede duplicar el movimiento de stock.
Loguea el code, no el message
Los mensajes pueden cambiar; los códigos son la superficie estable para ramificar tu lógica de integración.
// Correcto
if (error.code === "forbidden/inventory-handler") {
await notifyMerchant("Este SKU se gestiona por integración, no manualmente");
}
// Frágil
if (error.message.includes("handler")) { ... }