# Gestionar errores

Qué trae un UtiliaSDKError, cómo se traduce cada respuesta HTTP, qué se reintenta y cómo reaccionar a cada caso en JavaScript y Python.

Todo lo que falla en el SDK llega como un `UtiliaSDKError`: una respuesta de error de la API, un fallo de red o un dato que el propio SDK rechaza antes de enviarlo. Así puedes tratar todos los casos con un único bloque de captura y decidir qué hacer mirando un código, no el texto del mensaje.

## Qué trae el error

| JavaScript | Python | Qué contiene |
| - | - | - |
| `code` | `code` | Tipo de error, del enumerado `ErrorCode` (tabla siguiente). |
| `message` | `message` | Texto legible. Siempre es una cadena. |
| `statusCode` | `status_code` | Código HTTP de la respuesta, o vacío si la petición no llegó a enviarse. |
| `errorCode` | `error_code` | Código de negocio del servidor, por ejemplo `TICKET_NOT_FOUND`. Es lo que debes comparar para casos concretos. |
| `requestId` | `request_id` | Identificador de la petición (`x-request-id`), para buscarla en los registros del servidor. |
| `timestamp` | `timestamp` | Momento del error (`Date` en JavaScript; `datetime` en UTC en Python). |
| `details` | `details` | Detalle estructurado que mande el servidor. No lo enseñes tal cual a tus usuarios. |
| `validationErrors` | No existe | Solo JavaScript: lista normalizada de errores de validación por campo, para resaltar campos o registrar. |
| `planContext` | `plan_context` | Solo en los bloqueos por plan: qué aplicativo o capacidad falta. Ver [Errores de plan](https://os.utilia.ai/docs/developers/sdk/plan-errors). |
| `fixedBy` | `fixed_by` | Quién lo arregla, según el servidor: `developer` (quien programa la integración), `organization` (un administrador de la organización) o `utilia`. Desde la 4.6.0. |
| `docsUrl` | `docs_url` | El apartado de la documentación que explica ese código. Desde la 4.6.0. |
| `requiredScopes` | `required_scopes` | En el 403 `INSUFFICIENT_SCOPE`, los permisos de la API que faltan: se los pides a la organización. Desde la 4.6.0. |
| `retryAfterMs` | `retry_after` | Cuánto pide esperar el servidor antes de repetir (milisegundos en JavaScript, segundos en Python). Llega con el 429 y con el 503 que la traen. Desde la 4.6.0. |

`readErrorGuidance` (`read_error_guidance`) saca esos tres primeros datos de cualquier cuerpo de error, también si llamas a la API por tu cuenta. Qué significa cada `errorCode` y qué hacer con él está en [Errores, identificador de petición y reintentos](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries).

## Códigos y respuestas HTTP

| `ErrorCode` | Cuándo aparece |
| - | - |
| `VALIDATION_ERROR` | HTTP 400 o 422, o un dato que el SDK rechaza antes de enviar. |
| `UNAUTHORIZED` | HTTP 401: clave no válida o ausente, o sesión OAuth que ya no se puede renovar. |
| `FORBIDDEN` | HTTP 403: aplicación desactivada, origen no permitido, scope o permiso insuficiente, o bloqueo por plan. |
| `NOT_FOUND` | HTTP 404. |
| `CONFLICT` | HTTP 409: la operación choca con el estado actual del recurso. El motivo va en `errorCode`. Desde la 4.6.0; antes llegaba como `UNKNOWN`, así que si tu código distinguía el conflicto con `UNKNOWN`, compáralo con `CONFLICT` o, mejor, con `statusCode` 409 y su `errorCode`. |
| `RATE_LIMITED` | HTTP 429, o un 403 cuyo mensaje habla de «rate limit». |
| `NETWORK_ERROR` | Sin respuesta del servidor: tiempo agotado, conexión rechazada o cortada. |
| `UNKNOWN` | HTTP 500, 502, 503 o 504, y cualquier otro código no previsto. |
| `AUTH_EXPIRED` | Declarado, pero el SDK no lo emite hoy: una sesión caducada llega como `UNAUTHORIZED`. |

## Comprobaciones rápidas

El error trae comprobaciones ya hechas. En JavaScript son métodos (con paréntesis); en Python, propiedades (sin paréntesis).

| JavaScript | Python | Verdadero cuando |
| - | - | - |
| `isRetryable()` | `is_retryable` | Tiene sentido volver a intentarlo: `RATE_LIMITED` o `NETWORK_ERROR`, salvo que el servidor diga expresamente lo contrario. |
| `isRateLimited()` | `is_rate_limited` | Es un límite de peticiones. |
| `isUnauthorized()` | `is_unauthorized` | La credencial no vale. |
| `isPlanError()` | `is_plan_error` | Lo bloquea el plan de la organización, no un permiso. |
| `isModuleNotEnabled()` | `is_module_not_enabled` | Falta el aplicativo entero. |
| `isCapabilityNotEnabled()` | `is_capability_not_enabled` | El aplicativo está, pero falta una capacidad concreta. |

Desde la 4.6.0, la función `isInsufficientScopeError(error)` (`is_insufficient_scope_error(error)`) dice si falta un permiso de la API, y `error.requiredScopes` (`error.required_scopes`), cuál.

Para registrar el error en tus trazas, `toJSON()` (`to_dict()` en Python) lo convierte en un objeto plano con el código, el mensaje, la fecha, el estado HTTP, el identificador de petición y el contexto de plan.

## Capturar y reaccionar

Mira primero si es un bloqueo de plan y después el `code`, nunca el texto del mensaje. Cuando el error te llega, el SDK ya ha reintentado lo que tenía sentido reintentar.

**¿Qué hago con este error?**

¿Lo bloquea el plan de la organización (isPlanError)?

- Sí: [**Falta contratarlo**](https://os.utilia.ai/docs/developers/sdk/plan-errors) (`MODULE_NOT_ENABLED o CAPABILITY_NOT_ENABLED`). Ni reintentar ni dar permisos lo arregla: planContext dice qué aplicativo o capacidad falta.
- No: ¿Qué code trae el error?
  - `VALIDATION_ERROR`: [**Corrige los datos**](https://os.utilia.ai/docs/developers/sdk/limits). Sin statusCode, lo rechazó el SDK antes de enviar; con 400 o 422, el servidor. Repetirlo igual da el mismo error.
  - `UNAUTHORIZED`: [**Revisa la credencial**](https://os.utilia.ai/docs/developers/sdk/oauth) (`401`). La clave no vale o falta, o la sesión OAuth ya no se puede renovar: pide a la persona que vuelva a autorizar.
  - `FORBIDDEN`: [**Pide el scope o el permiso**](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries) (`403`). Con errorCode INSUFFICIENT_SCOPE, requiredScopes dice qué permiso de la API falta; si no, la aplicación está desactivada, sin responsable activo o el origen no está permitido.
  - Otro código: ¿Cuál de los demás?
    - NOT_FOUND o CONFLICT: **Revisa el recurso** (`404 o 409`). El errorCode dice qué no existe o con qué estado choca, por ejemplo TICKET_NOT_FOUND. Repetirlo igual no lo arregla, salvo con retryable: true.
    - `RATE_LIMITED`: [**Espera y vuelve a intentarlo**](https://os.utilia.ai/docs/developers/getting-started/rate-limits-and-cors) (`429`). El SDK ya esperó lo que pedía el servidor. Si la espera pasaba de maxRetryAfterMs, no reintentó: retryAfterMs dice cuánto esperar.
    - `NETWORK_ERROR`: [**Mira si se hizo antes de repetir**](https://os.utilia.ai/docs/developers/sdk/invoices). No hubo respuesta. Las altas sin clave de idempotencia y las escrituras que mueven dinero no se reintentan y pudieron hacerse: consulta su estado primero.
    - `UNKNOWN`: **Fallo del servidor** (`5xx`). El SDK ya lo reintentó. Guarda el requestId para soporte y, si era una escritura que mueve dinero, consulta su estado antes de repetirla.

_Los errores de red, los 429 y los 5xx ya se reintentaron antes de llegarte, salvo las altas sin clave de idempotencia y las escrituras que mueven dinero, que hacen un solo intento._

El mismo criterio, en código:

### JavaScript

```typescript
import { ErrorCode, UtiliaSDKError } from '@utilia-os/sdk-js';

try {
  await sdk.tickets.create({
    user: { externalId: 'usr_123' },
    title: 'Error en el formulario de pago',
    description: 'Al enviar el formulario aparece un error 500 en la consola.',
  });
} catch (error) {
  if (!(error instanceof UtiliaSDKError)) throw error;

  if (error.isPlanError()) {
    // No se arregla reintentando ni dando permisos: hay que contratar.
    return;
  }

  switch (error.code) {
    case ErrorCode.VALIDATION_ERROR:
      console.error('Datos no válidos:', error.message);
      break;
    case ErrorCode.UNAUTHORIZED:
      console.error('Revisa la clave o la sesión OAuth.');
      break;
    case ErrorCode.FORBIDDEN:
      // Desde la 4.6.0, requiredScopes dice qué permiso de la API falta.
      console.error('Falta un permiso:', error.requiredScopes ?? error.errorCode, error.docsUrl);
      break;
    case ErrorCode.NOT_FOUND:
      console.error('No existe:', error.errorCode);
      break;
    case ErrorCode.CONFLICT:
      console.error('Choca con el estado del recurso:', error.errorCode);
      break;
    default:
      console.error(`[${error.code}] ${error.message} (petición ${error.requestId})`);
  }
}
```

### Python

```python
from utilia_sdk import (
    CreateTicketInput,
    CreateTicketUser,
    ErrorCode,
    UtiliaSDKError,
)

try:
    await sdk.tickets.create(
        CreateTicketInput(
            user=CreateTicketUser(external_id="usr_123"),
            title="Error en el formulario de pago",
            description="Al enviar el formulario aparece un error 500 en la consola.",
        )
    )
except UtiliaSDKError as error:
    if error.is_plan_error:
        # No se arregla reintentando ni dando permisos: hay que contratar.
        pass
    elif error.code == ErrorCode.VALIDATION_ERROR:
        print(f"Datos no válidos: {error.message}")
    elif error.code == ErrorCode.UNAUTHORIZED:
        print("Revisa la clave o la sesión OAuth.")
    elif error.code == ErrorCode.FORBIDDEN:
        # Desde la 4.6.0, required_scopes dice qué permiso de la API falta.
        print(f"Falta un permiso: {error.required_scopes or error.error_code} {error.docs_url}")
    elif error.code == ErrorCode.NOT_FOUND:
        print(f"No existe: {error.error_code}")
    elif error.code == ErrorCode.CONFLICT:
        print(f"Choca con el estado del recurso: {error.error_code}")
    else:
        print(f"[{error.code.value}] {error.message} (petición {error.request_id})")
```

## Errores de las credenciales

Desde la 4.2.0, los dos SDK conocen y tipan los códigos de negocio de las credenciales. En JavaScript, `CREDENTIAL_ERROR_CODES` los reúne, `isCredentialErrorCode(error.errorCode)` dice si un código es de esa familia y el tipo `KnownErrorCode` tipa `error.errorCode`. En Python, `CredentialErrorCode` (comparable con `error.error_code`) e `is_credential_error_code`. Los que más vas a ver:

| errorCode | HTTP | Qué hacer |
| - | - | - |
| `APP_RESPONSIBLE_INACTIVE` | 403 | La persona responsable de la aplicación ya no está activa. Pide en la organización que nombren a otra en la ficha de la aplicación; reintentar no lo arregla. |
| `API_KEY_WRONG_KIND` | 401 | La credencial es de otro tipo: una clave de la organización (`utk_`) contra la API externa, o la clave de la aplicación (`utl_`) en `Authorization: Bearer` contra una ruta del panel. Usa la credencial de esa API. Tipado desde la 4.6.0. |
| `API_KEY_WRONG_HEADER` | 401 | La clave de la aplicación llegó en `Authorization: Bearer` y no en `X-Api-Key`. El SDK la pone siempre en su sitio: solo aparece si llamas por tu cuenta. Tipado desde la 4.6.0. |
| `AUTH_TOKEN_INVALID` | 401 | Una ruta del panel no acepta el token de sesión: falta, está mal formado o ha caducado. No llega desde la API externa. Tipado desde la 4.6.0. |
| `API_KEY_IN_URL_NOT_ALLOWED` | 401 | La clave llegó en la dirección. Envíala en la cabecera `X-Api-Key` y, para una conexión en tiempo real, pide antes un pase de conexión (el SDK lo hace solo desde la 4.2.0). |
| `STREAM_TOKEN_INVALID` | 401 | El pase de conexión ya se usó o ha caducado. Pide otro. |
| `STREAM_TOKEN_USER_REQUIRED` | 400 | Una aplicación con widget abrió la conexión de eventos sin el usuario. |
| `STREAM_TOKEN_UNAVAILABLE` | 503 | No se pudo emitir el pase. Se puede reintentar a los pocos segundos. |
| `OAUTH_BEARER_NOT_ALLOWED` | 401 | La ruta exige la sesión de una persona y no admite el token OAuth. El SDK no renueva el token ni repite la petición. |
| `METHOD_NOT_AVAILABLE_WITH_API_KEY` | Sin respuesta | Lo lanza el propio SDK, con `code` `FORBIDDEN` y sin hacer la petición, cuando el método va a una ruta que la clave de la aplicación no alcanza. El mensaje dice qué usar en su lugar. |

> **Atención: Cambio en la 4.2.0 si decidías por el código**
>
> Una aplicación cuya persona responsable no está activa recibía antes `401 API_KEY_INVALID` o `422 EXTERNAL_APP_CREATOR_MISSING`; ahora recibe `403 APP_RESPONSIBLE_INACTIVE`. `EXTERNAL_APP_CREATOR_MISSING` queda solo para una aplicación sin organización vinculada. Si tu código decidía por los códigos antiguos, contempla el nuevo.

## Confirmar una operación en el móvil

Algunas operaciones MCP, como renovar la clave de una aplicación externa o su clave de firma, piden a la persona que las confirme en el móvil si tiene la verificación en dos pasos. La primera llamada falla con `APPROVAL_REQUIRED`, y `getApprovalChallenge(error)` (`get_approval_challenge` en Python) devuelve `approvalId`, el número (`number`) que la persona tiene que elegir y `expiresAt`. Cuando lo aprueba, repite exactamente la misma llamada con `twoFactorApprovalId` (`two_factor_approval_id`). `isApprovalRequired(error)` (`is_approval_required`) responde si el error es de este tipo. El SDK nunca acepta códigos de verificación por esta vía.

### JavaScript

```typescript
import { getApprovalChallenge } from '@utilia-os/sdk-js';

try {
  await sdk.mcp.integrations.appsRegenerateApiKey(appId, { graceDays: 7 });
} catch (error) {
  const reto = getApprovalChallenge(error);
  if (!reto) throw error;
  console.log(`Elige el número ${reto.number} en el móvil.`);
  // Cuando la persona lo apruebe:
  await sdk.mcp.integrations.appsRegenerateApiKey(appId, {
    graceDays: 7,
    twoFactorApprovalId: reto.approvalId,
  });
}
```

### Python

```python
from utilia_sdk import UtiliaSDKError, get_approval_challenge

try:
    await sdk.mcp.integrations.apps_regenerate_api_key(app_id, grace_days=7)
except UtiliaSDKError as error:
    reto = get_approval_challenge(error)
    if reto is None:
        raise
    print(f"Elige el número {reto.number} en el móvil.")
    # Cuando la persona lo apruebe:
    await sdk.mcp.integrations.apps_regenerate_api_key(
        app_id, grace_days=7, two_factor_approval_id=reto.approval_id
    )
```

Si la persona no puede aprobarla en su móvil, renueva la clave desde la ficha de la aplicación, con su código de verificación.

## Reintentos

El SDK ya reintenta por su cuenta los errores de red, los 429 y los 5xx, hasta el número total de intentos de `retryAttempts` (`retry_attempts`), que por defecto es 3. Desde la 4.6.0:

- **Espera lo que pide el servidor.** Con `Retry-After` espera ese tiempo. `X-Utilia-Quota-Reset` solo cuenta en el 429 del cupo por hora agotado que no trae `Retry-After`; un 5xx u otro 429 sin `Retry-After` se repite con la espera creciente de siempre: 0,5 s, 1 s, 2 s… Si el servidor pide más de `maxRetryAfterMs` (`max_retry_after`, un minuto por defecto), como cuando se agota el cupo por hora, no reintenta: el error llega enseguida con `retryAfterMs` (`retry_after`).
- **No repite lo que podría duplicar.** Un `POST` sin clave de idempotencia (una alta de ticket, mensaje, cliente, lead, archivo o IA) se intenta una sola vez, y las escrituras que mueven dinero, como `invoices.create` e `invoices.cancel`, también. Si repites una a mano, conserva la clave de idempotencia y los datos originales.

Los detalles están en [Configurar el cliente](https://os.utilia.ai/docs/developers/sdk/configuration#reintentos).

El envío de errores va aparte: `errors.reportBatch` y `ai.reportError` (`report_batch`, `report_error`) hacen un solo intento, y la cola de `errors.install` y `capture` tiene sus propios reintentos, que nunca repiten antes de lo que pide `Retry-After` ([Capturar los errores de tu aplicación](https://os.utilia.ai/docs/developers/sdk/error-reporting)).

Si prefieres controlar tú los reintentos, pon `retryAttempts: 1` (o `retry_attempts=1`), usa la comprobación `isRetryable` y, cuando el servidor diga cuánto esperar, respeta `retryAfterMs` (`retry_after`, en segundos):

### JavaScript

```typescript
import { UtiliaSDKError } from '@utilia-os/sdk-js';

async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const retryable = error instanceof UtiliaSDKError && error.isRetryable();
      if (!retryable || attempt === maxAttempts) throw error;
      const waitMs = error.retryAfterMs ?? 1000 * 2 ** (attempt - 1);
      await new Promise((resolve) => setTimeout(resolve, waitMs));
    }
  }
}

const ticket = await withRetry(() => sdk.tickets.get('ticket-uuid', 'usr_456'));
```

### Python

```python
import asyncio

from utilia_sdk import UtiliaSDKError


async def with_retry(fn, max_attempts: int = 3):
    for attempt in range(1, max_attempts + 1):
        try:
            return await fn()
        except UtiliaSDKError as error:
            if not error.is_retryable or attempt == max_attempts:
                raise
            wait = error.retry_after
            await asyncio.sleep(wait if wait is not None else 2 ** (attempt - 1))


ticket = await with_retry(lambda: sdk.tickets.get("ticket-uuid", "usr_456"))
```

## Errores antes de enviar la petición

El SDK comprueba algunos datos antes de llamar a la API. Cuando falla una de esas comprobaciones, el error no tiene código HTTP ni identificador de petición, porque la petición no llegó a salir.

**Dónde salta un dato no válido**

### En el método del servicio (JavaScript)

Un UtiliaSDKError con código VALIDATION_ERROR, sin statusCode ni requestId. Lo captura el mismo bloque que los errores de la API.

### Al construir el modelo (Python)

Un pydantic.ValidationError de CreateTicketInput, AddMessageInput, IdentifyUserInput… No es un UtiliaSDKError: captura los dos con except (ValidationError, UtiliaSDKError) y construye el modelo dentro del try.

La lista completa de comprobaciones y sus límites está en [Límites y validaciones](https://os.utilia.ai/docs/developers/sdk/limits).

## Siguiente paso

- [Errores de plan](https://os.utilia.ai/docs/developers/sdk/plan-errors): Distingue un aplicativo o una capacidad sin contratar de un permiso que falta.
- [Errores, identificador de petición y reintentos](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries): El mismo tema visto desde la API REST, con los códigos de negocio más habituales.
