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]
- 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.
- 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:
| Estado | Tipo | Descripción | Transiciones permitidas |
|---|---|---|---|
planned | Transitorio | Intento registrado localmente con huella segura del payload. | submitted, rejected |
submitted | Transitorio | Bytes enviados al endpoint remoto; esperando respuesta. | succeeded, rejected, outcome_unknown |
outcome_unknown | Suspendido | La red cayó o expiró tras el envío. Reintentos bloqueados. | reconciling, manual_review |
reconciling | Activo | Consultando al sistema externo por prueba del efecto. | succeeded, safe_to_retry, manual_review |
succeeded | Terminal | ID externo verificado vía respuesta o readback. | Ninguna |
safe_to_retry | Terminal | Ausencia de efecto probada vía readback autoritativo. | Ninguna (se requiere nuevo intento) |
rejected | Terminal | El servidor devolvió un error de cliente determinista (4xx). | Ninguna |
manual_review | Terminal | La 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:
- 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).
- 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
- 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.
- 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. - 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.