Cuando la petición HTTP de un agente de IA —o una llamada a herramienta en el navegador— expira por timeout, ¿qué registra tu sistema?

Si registra failed, el agente tiene un punto ciego. Un timeout de red no significa que la operación haya fallado en el servidor remoto; significa que la conexión se cerró antes de que el cliente recibiera la respuesta. Si el servidor procesó la mutación, reintentar la llamada a ciegas creará un artefacto duplicado: un doble pago, un ticket duplicado, un correo repetido o un artículo redundante.

Si registra succeeded, está alucinando certeza.

El estado que falta es outcome_unknown — un estado operativo de primera clase que detiene los reintentos automáticos, registra la mutación no confirmada y entrega la ejecución a un bucle de reconciliación explícito.

En un artículo anterior hablamos de por qué los agentes necesitan recibos de acción en lugar de solo memoria semántica. Tras valiosas discusiones con profesionales de sistemas distribuidos y límites de memoria, este artículo convierte ese concepto en una máquina de estado concreta y testeable que puedes incorporar a cualquier framework de agentes en producción.

La línea divisoria: fallo pre-envío vs. ambigüedad post-envío

No todas las excepciones son iguales:

[Intento Registrado]
       |
       v
[Intentando Transporte] ---> (Error DNS / Socket local / Auth) ---> [RECHAZADO / SEGURO_REINTENTAR]
       |
  (Bytes enviados)
       |
       v
[Esperando Respuesta]   ---> (Timeout de conexión / Drop / 504)  ---> [RESULTADO_DESCONOCIDO]
  1. Fallos pre-envío: si el lookup de DNS falla, faltan credenciales localmente, o la conexión es rechazada antes de que un solo byte salga del socket, el mundo no ha cambiado. La acción está determinísticamente sin ejecutar y es seguro reintentarla.
  2. Ambigüedad post-envío: en el momento en que los bytes cruzan el cable, el fallo de transporte deja de ser un indicador del estado del servidor. El servidor puede haber confirmado la mutación y colapsado durante la serialización de la respuesta, o un proxy intermedio puede haber expirado tras 30 segundos mientras el worker del backend terminaba el trabajo.

Tratar la ambigüedad post-envío como un fallo es la causa raíz de las tormentas de duplicados automatizados.

La máquina de estado

Este es el ciclo de vida completo de una acción de agente protegida:

EstadoTipoDescripciónTransiciones permitidas
plannedTransitorioIntento registrado localmente con huella segura del payload.submitted, rejected
submittedTransitorioBytes enviados al endpoint remoto; esperando respuesta.succeeded, rejected, outcome_unknown
outcome_unknownSuspendidoLa red cayó o expiró tras el envío. Reintentos bloqueados.reconciling, manual_review
reconcilingActivoConsultando al sistema externo por prueba del efecto.succeeded, safe_to_retry, manual_review
succeededTerminalID externo verificado vía respuesta o readback.Ninguna
safe_to_retryTerminalAusencia de efecto probada vía readback autoritativo.Ninguna (se requiere nuevo intento)
rejectedTerminalEl servidor devolvió un error de cliente determinista (4xx).Ninguna
manual_reviewTerminalLa ausencia/presencia no puede probarse programáticamente.Intervención humana

Claves de idempotencia vs. huellas de intención

En la ingeniería de pagos, el consenso distribuido se logra con entrega al-menos-una-vez combinada con una clave de deduplicación del lado del servidor (una Idempotency Key).

Cuando una plataforma externa soporta nativamente cabeceras de idempotencia (como Idempotency-Key: en Stripe o las claves de mutación de GitHub GraphQL), la reconciliación es directa: si hay timeout, reenvías con exactamente la misma clave.

Sin embargo, la gran mayoría de las APIs web, servicios CRUD y superficies controladas por navegador no soportan claves de idempotencia nativas. En esos entornos, la responsabilidad recae en el llamador:

  1. Huella de intención normalizada: antes de enviar, calcula un hash criptográfico canónico del payload semántico (tipo de mutación, recurso objetivo, campos normalizados del cuerpo).
  2. Reconciliación read-after-write: cuando se dispara outcome_unknown, el agente consulta la API de lectura (o el endpoint de búsqueda) buscando recursos creados por la cuenta del agente dentro de una ventana de tiempo acotada que coincida con la huella de intención.
import hashlib
import json

def compute_intent_fingerprint(method: str, path: str, payload: dict) -> str:
    canonical = json.dumps(
        {"method": method.upper(), "path": path, "payload": payload},
        sort_keys=True,
        separators=(",", ":")
    )
    return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"

Conservando el registro de auditoría de la ambigüedad

Una trampa sutil en el diseño de máquinas de estado son las actualizaciones destructivas in-place.

Si una acción pasa por submitted -> outcome_unknown -> reconciling -> succeeded y simplemente sobrescribes el estado a succeeded, destruyes el registro histórico de que la acción vivió en un estado indeterminado durante horas.

Durante los post-mortems (o al auditar condiciones de carrera donde otro worker observó el estado ausente durante esa ventana), saber cómo llegó una acción al éxito es tan crítico como el estado final.

Un recibo de acción robusto preserva la trayectoria completa de transiciones:

{
  "operation_id": "20260818T190000Z-a1b2c3d4e5",
  "operation": "articles.create",
  "state": "succeeded",
  "intent_fingerprint": "sha256:4d8a...",
  "state_history": [
    { "state": "planned", "recorded_at": "2026-08-18T19:00:00Z" },
    { "state": "submitted", "recorded_at": "2026-08-18T19:00:01Z" },
    { 
      "state": "outcome_unknown", 
      "recorded_at": "2026-08-18T19:00:31Z",
      "error": { "code": "timeout", "message": "Gateway Timeout 504" }
    },
    { 
      "state": "reconciling", 
      "recorded_at": "2026-08-18T19:05:00Z" 
    },
    { 
      "state": "succeeded", 
      "recorded_at": "2026-08-18T19:05:02Z",
      "reconciliation": {
        "evidence": "Readback de /api/articles coincidió con la huella del título",
        "external_id": 4407310
      }
    }
  ]
}

Implementación mínima funcional

Esta es una implementación en Python del patrón de ejecución protegida y reconciliación:

from dataclasses import dataclass, field
from datetime import datetime, timezone
import uuid

@dataclass
class ActionReceipt:
    operation_id: str
    action: str
    target: str
    fingerprint: str
    state: str = "planned"
    external_id: str | None = None
    state_history: list[dict] = field(default_factory=list)

    def transition_to(self, new_state: str, **meta):
        self.state = new_state
        self.state_history.append({
            "state": new_state,
            "recorded_at": datetime.now(timezone.utc).isoformat(),
            **meta
        })

def execute_guarded_action(client, action: str, target: str, payload: dict) -> ActionReceipt:
    receipt = ActionReceipt(
        operation_id=f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}",
        action=action,
        target=target,
        fingerprint=compute_intent_fingerprint("POST", target, payload)
    )
    receipt.transition_to("planned")

    # 1. Registrar el intento de forma persistente ANTES de tocar la red
    persist_receipt(receipt)

    receipt.transition_to("submitted")
    persist_receipt(receipt)

    try:
        response = client.post(target, json=payload, timeout=10.0)
        receipt.external_id = response.json().get("id")
        receipt.transition_to("succeeded", status_code=response.status_code)
    except TimeoutError as exc:
        # Crucial: NO reintentar. Marcar como ambiguo.
        receipt.transition_to("outcome_unknown", error=str(exc))
    except Exception as exc:
        receipt.transition_to("rejected", error=str(exc))
    finally:
        persist_receipt(receipt)

    return receipt

def reconcile_receipt(client, receipt: ActionReceipt, read_fn) -> ActionReceipt:
    if receipt.state != "outcome_unknown":
        return receipt

    receipt.transition_to("reconciling")
    persist_receipt(receipt)

    matched_item = read_fn(client, receipt.fingerprint)
    if matched_item:
        receipt.external_id = matched_item["id"]
        receipt.transition_to("succeeded", evidence="Coincidió en consulta readback")
    else:
        # Si la ausencia se prueba de forma autoritativa, marca seguro para reintento fresco
        receipt.transition_to("safe_to_retry", evidence="Readback autoritativo mostró 0 registros")

    persist_receipt(receipt)
    return receipt

Limitaciones y consistencia eventual

  1. Retraso de consistencia eventual: en bases de datos distribuidas, un recurso recién creado puede no ser visible inmediatamente en las réplicas de lectura. Un bucle de reconciliación debe contemplar el retraso de propagación con backoff acotado, en lugar de concluir ausencia de inmediato.
  2. Endpoints de mutación ciega: si una API permite mutaciones pero no expone endpoints de listado, búsqueda o readback, la reconciliación no puede automatizarse. Esas acciones deben pasar a manual_review.
  3. Operaciones destructivas: las operaciones de borrado (DELETE) son inherentemente más difíciles de reconciliar porque la ausencia es el estado objetivo. Un elemento faltante podría significar que el borrado funcionó… o que el elemento nunca existió.

Discusión

Al construir agentes autónomos que interactúan con APIs externas o superficies de navegador:

¿Qué escritura externa en tus sistemas es la más difícil de reconciliar tras un timeout inesperado, y cómo evitas la ejecución duplicada?

En DojoFullStack creemos que los detalles como este —los estados que faltan, los reintentos que no deberían existir— son los que separan una demo impresionante de un agente que aguanta producción. Sigue explorando estos temas en el blog de DojoFullStack.