Saltar al contenido

Capítulo 11 de 18

Reintentos, idempotencia y cancelación

Distingue una operación fallida de una operación incierta y diseña una respuesta segura para cada frontera.

#Un timeout no significa que no ocurrió

Envías una petición para crear un ticket. El servidor lo crea, pero la respuesta se pierde. Tu cliente observa timeout. Repetir sin una identidad estable puede crear otro ticket; declarar fallo definitivo puede ocultar que el primero existe.

La idempotencia permite repetir una intención sin multiplicar su efecto. Depende del contrato del receptor, de cómo guarda la identidad y de cuánto tiempo la conserva. Una variable local llamada idempotency_key no obliga a una API externa a respetarla.

#Clasificar antes de reintentar

Situación Interpretación Respuesta razonable
Argumentos inválidos La petición está mal construida Devolver error al modelo o corregir; no repetir igual
Permiso denegado No existe autoridad para actuar Detener esa acción y explicar el bloqueo
Rate limit temporal Falta capacidad ahora Respetar espera indicada y presupuesto
Conflicto de versión El recurso cambió Leer de nuevo y replantear la intención
Timeout tras un envío Resultado desconocido Consultar estado o repetir con idempotencia válida
Error de programación Fallo del sistema Registrar y corregir; no disfrazarlo de respuesta del modelo

Distingue fallos de transporte, de protocolo y de negocio. Un HTTP 200 puede contener un resultado de herramienta fallido; un HTTP 500 no demuestra que el efecto no se haya ejecutado. La semántica la establece la operación concreta.

#Identidad de intención y hash de argumentos

Una operación tiene una identidad estable durante sus intentos. Si cambian los argumentos, normalmente es otra intención y necesita otra identidad. Para detectar uso accidental de la misma clave con otro cuerpo, guarda una representación canónica:

import hashlib
import json


def fingerprint(name, arguments):
    data = json.dumps(
        {"name": name, "arguments": arguments},
        sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False,
    ).encode("utf-8")
    return hashlib.sha256(data).hexdigest()

Ordenar claves hace que dos diccionarios con las mismas parejas clave–valor produzcan el mismo texto aunque se insertaran en distinto orden. separators evita espacios de formato y allow_nan=False rechaza valores numéricos especiales que no son JSON válido. El hash no sustituye la autorización ni resuelve diferencias semánticas como unidades o valores por defecto; normaliza esos aspectos antes si forman parte del contrato.

El receptor consulta la clave. Si existe con otro hash, rechaza. Si existe completada con el mismo hash, devuelve su resultado. Si está pendiente, informa de su estado o reanuda mediante un mecanismo definido. Para un efecto dentro de una base de datos, clave y efecto deben confirmarse en la misma transacción.

#Un receptor que pierde la respuesta sin duplicar el ticket

Vamos a implementar el receptor, que es donde debe cumplirse la garantía. No basta con que el agente recuerde qué envió. El caso crea un ticket y su recibo en SQLite; después simula que la respuesta no llega. Un segundo intento debe recuperar el resultado guardado, sin insertar otro ticket.

intento 1: clave k → [ticket + recibo] COMMIT → respuesta perdida
reinicio del cliente
intento 2: clave k → recibo existente → mismo ticket
intento 3: clave k + otro texto → rechazar intención distinta

La función fingerprint anterior y los tres bloques siguientes forman un programa completo con Python 3.12 o posterior y su biblioteca estándar. SQLite será el receptor simulado; no hay una API de tickets externa ni una cuenta necesaria. Separaremos el efecto confirmado del fallo de transporte a propósito.

#Dos tablas para una sola confirmación

tickets conserva el efecto. receipts conserva qué respuesta corresponde a cada intención. La clave es única dentro de un propietario: el mismo texto de clave utilizado por dos usuarios no debe hacer que uno reciba el ticket del otro. owner provendrá de la identidad autenticada del servidor, nunca de un argumento del modelo.

import sqlite3
import tempfile
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def transaction(db):
    db.execute("BEGIN IMMEDIATE")
    try:
        yield
        db.execute("COMMIT")
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise


def open_db(path):
    return sqlite3.connect(path, autocommit=True)


SCHEMA = """
CREATE TABLE tickets (
    id INTEGER PRIMARY KEY,
    owner TEXT NOT NULL,
    subject TEXT NOT NULL
);
CREATE TABLE receipts (
    owner TEXT NOT NULL,
    request_key TEXT NOT NULL,
    arguments_hash TEXT NOT NULL,
    result TEXT NOT NULL,
    PRIMARY KEY (owner, request_key)
);
"""

La transacción se comporta como en persistencia: yield ejecuta el cuerpo del with, después confirma o revierte y propaga el error. BEGIN IMMEDIATE impide que dos escritores SQLite hagan a la vez «no existe recibo» e inserten dos efectos. La clave primaria es otra defensa; por sí sola, situada en una transacción distinta del ticket, llegaría demasiado tarde.

#Ejecutar una vez o devolver el recibo

Primero validamos la petición. Después consultamos el recibo dentro de la transacción. Solo la rama sin recibo crea el ticket. lose_response y fail_before_commit son interruptores de prueba para observar dos fallos diferentes; no forman parte de los argumentos que puede elegir el modelo.

def create_ticket(db, owner, request_key, arguments, *,
                  lose_response=False, fail_before_commit=False):
    if not isinstance(arguments, dict) or set(arguments) != {"subject"}:
        raise ValueError("Se requiere únicamente subject")
    subject = arguments["subject"]
    if not isinstance(subject, str) or not subject.strip() or len(subject) > 120:
        raise ValueError("subject debe contener texto y ocupar como máximo 120 caracteres")
    if not isinstance(request_key, str) or not 1 <= len(request_key) <= 100:
        raise ValueError("Clave de petición inválida")
    digest = fingerprint("create_ticket", arguments)
    with transaction(db):
        receipt = db.execute(
            "SELECT arguments_hash, result FROM receipts "
            "WHERE owner=? AND request_key=?", (owner, request_key),
        ).fetchone()
        if receipt is not None:
            if receipt[0] != digest:
                raise ValueError("La clave ya corresponde a otra intención")
            result = json.loads(receipt[1])
        else:
            created = db.execute(
                "INSERT INTO tickets(owner, subject) VALUES (?, ?)",
                (owner, subject),
            )
            result = {"ticket_id": created.lastrowid, "subject": subject}
            db.execute(
                "INSERT INTO receipts VALUES (?, ?, ?, ?)",
                (owner, request_key, digest, json.dumps(result)),
            )
        if fail_before_commit:
            raise RuntimeError("Fallo antes de confirmar")
    # El efecto ya quedó confirmado: este fallo no hace ROLLBACK.
    if lose_response:
        raise TimeoutError("El receptor confirmó, pero la respuesta se perdió")
    return result

fetchone() devuelve una tupla o None: la posición 0 contiene el hash y la 1, el JSON del resultado. lastrowid obtiene el identificador asignado por SQLite. json.loads recupera exactamente los datos del recibo; no reconstruimos la respuesta consultando un ticket que quizá haya cambiado después.

Observa dónde se lanza TimeoutError: después de salir correctamente del with. Si estuviese dentro, el ejemplo probaría un rollback, no una respuesta perdida tras un efecto. Ambas situaciones aparecen iguales ante un cliente con timeout; su diferencia está en el receptor.

#Comprobar los efectos después de reabrir

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "tickets.db"
    db = open_db(path)
    db.executescript(SCHEMA)
    arguments = {"subject": "Investigar fallo de API"}
    try:
        create_ticket(db, "u-1", "op-17", arguments, lose_response=True)
    except TimeoutError:
        print("Intento 1: respuesta desconocida para el cliente")
    db.close()

    db = open_db(path)
    repeated = create_ticket(db, "u-1", "op-17", arguments)
    assert repeated == {"ticket_id": 1, "subject": arguments["subject"]}
    assert db.execute("SELECT COUNT(*) FROM tickets").fetchone()[0] == 1
    assert create_ticket(db, "u-1", "op-17", arguments) == repeated
    try:
        create_ticket(db, "u-1", "op-17", {"subject": "Otro problema"})
    except ValueError:
        print("Otra intención con la misma clave: rechazada")
    else:
        raise AssertionError("Se reutilizó una identidad con otros argumentos")
    assert db.execute("SELECT COUNT(*) FROM tickets").fetchone()[0] == 1
    assert db.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == 1

    try:
        create_ticket(db, "u-1", "op-18", arguments, fail_before_commit=True)
    except RuntimeError:
        pass
    # Este segundo fallo sí revirtió ticket y recibo nuevos.
    assert db.execute("SELECT COUNT(*) FROM tickets").fetchone()[0] == 1
    assert db.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == 1
    db.close()
    print("Reintentos equivalentes: un ticket y un recibo durables")

Las aserciones comprueban filas reales, no el número de veces que se llamó a la función. Reabrir la base también descarta la explicación engañosa de «funciona porque el resultado sigue en una variable». El último fallo comprueba que ticket y recibo no quedan separados.

Este diseño funciona porque el receptor controla ambas tablas en la misma base. Sustituir el INSERT INTO tickets por una llamada HTTP dentro del with no extiende la atomicidad al otro servidor. En ese caso necesitas la clave idempotente del proveedor, una consulta de reconciliación o declarar incertidumbre, como explica la siguiente sección.

Tampoco hemos eliminado recibos. Si su política los borra a las 24 horas, repetir una petición antigua después de ese plazo puede crear otro efecto. Documenta retención, ámbito, formato y consulta de estado. La garantía vale para ese contrato y ese almacenamiento; no es una propiedad universal del nombre request_key.

#Cuando el destino no ofrece idempotencia

Puedes buscar por un identificador de negocio único si el destino lo admite. Por ejemplo, un ticket con external_reference=operation_id. Pero consultar «¿existe?» y después crear no es seguro si dos peticiones pueden intercalarse; la unicidad debe aplicarla el receptor.

Si no hay clave, consulta de estado ni precondición, la plataforma no puede garantizar que repetir sea seguro. Marca unknown o needs_reconciliation, conserva evidencia y pide una decisión operativa cuando corresponda. Es mejor expresar incertidumbre que afirmar «exactamente una vez» sin mecanismo que lo sostenga.

#Backoff sin provocar una tormenta

Un reintento inmediato de miles de clientes puede saturar aún más un servicio. Backoff aumenta la espera entre intentos; jitter añade variación para que no despierten todos juntos. Este fragmento calcula una espera, no realiza el envío:

import random


def delay(attempt, base=0.5, cap=20.0):
    maximum = min(cap, base * (2 ** attempt))
    return random.uniform(0, maximum)

Limita el número de intentos y el tiempo total. Si el proveedor devuelve una espera obligatoria, respétala según su contrato. Un retry debe consumir el presupuesto del run. Si el SDK ya reintenta tres veces y tu capa hace otras tres, puedes terminar con nueve solicitudes; elige quién controla la política.

Para esperas largas, guarda next_attempt_at y libera el worker. Un sleep de veinte minutos ocupa capacidad y dificulta despliegues. La orquestación permite reactivar la tarea cuando corresponda.

#Cancelar es una transición, no viajar atrás

Una cancelación solicita que no se inicien nuevas acciones. El worker debe comprobarla antes de una inferencia costosa, después de recibir su resultado y antes de ejecutar cada efecto. Para herramientas largas, propaga cancelación cooperativa o termina la unidad de ejecución cuando su contrato lo permita.

Existe una carrera: la solicitud puede llegar justo cuando una operación se está enviando. Debes definir cuándo se acepta una cancelación y qué significa su resultado. «Cancelación solicitada» y «ejecución detenida» son estados distintos. Si había un efecto en curso, conserva su estado conocido o incierto.

cancel_requested → no iniciar nuevas operaciones
                 → detener/reconciliar las que estaban en curso
                 → guardar efectos conocidos
                 → status=cancelled o status=waiting, reason=reconciliation

Aquí waiting es el estado operativo y reconciliation explica su causa; no añadimos un estado terminal ficticio. Una API que devuelve «cancelado» inmediatamente mientras siguen publicándose cambios ofrece una promesa falsa. La interfaz debe mostrar qué frontera se ha alcanzado.

#Compensar es una acción nueva

Deshacer una reserva puede ser posible; deshacer un correo leído no. Una compensación es una operación de negocio que intenta revertir un efecto: anular una reserva, devolver un importe o retirar un recurso. Puede fallar y necesita su propia identidad, autorización y seguimiento.

En un flujo de varios servicios, registra qué pasos se confirmaron y qué compensaciones corresponden. No uses el rollback de una base local como si deshiciera llamadas remotas. Un sistema pequeño puede resolverlo con una máquina de estados explícita; no necesita adoptar un framework de sagas antes de entender qué efectos son reversibles.

#Probar justo donde hay incertidumbre

El receptor anterior ya ensaya respuesta perdida, repetición, conflicto de intención y fallo antes de commit. Para adaptar esa prueba a una API real, utiliza su entorno de pruebas y conserva la identidad remota: un contador local no demuestra cuántos tickets creó otro servidor. Comprueba también qué ocurre tras vencer la retención de claves del destino.

Prueba cancelación antes del envío, durante la espera y después de confirmación. Cada caso debe informar de una situación distinta. Las pruebas de «la función lanzó timeout» no son suficientes: el valor está en comprobar el efecto que quedó en el receptor.

#Referencias

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar