Cargando
Miniatura del artículo

Arquitecto de automatización con IA: Día 40

Índice de contenido

Resumen:
El servicio de facturación de Trigo Lento no tiene nodo en n8n, y la Cafetería Brújula necesita su factura cada sábado. En esta entrada, Valeria aprende a traducir la documentación de una API REST a una petición del nodo HTTP Request: método, URL, parámetros, cabeceras, cuerpo y códigos de estado; autentica con credenciales genéricas sin dejar secretos en los parámetros, recorre resultados paginados por cursor, respeta los límites de tasa con Retry-After, llama a Claude por HTTP cuando el subnodo de modelo no basta y construye el sub-workflow TL Facturación, con una clave de idempotencia que impide emitir dos veces la misma factura. Al terminar, usted sabrá integrar cualquier servicio que tenga una API documentada, aunque nadie haya escrito un conector para él.
¿Cómo citar el presente artículo?
Romero, J. (12 de marzo de 2026). Arquitecto de automatización con IA: Día 40. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_40/.
Arquitecto de automatización con IA: Día 40 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_40/.

Planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados en octubre de 2026. Los nombres internos de los parámetros del nodo HTTP Request (authentication, genericAuthType, headerParameters, specifyBody, jsonBody, options.pagination, options.batching, options.response, options.timeout) se contrastaron con la documentación oficial de n8n y con el código fuente publicado del nodo en octubre de 2026: las versiones del tipo de la 3 a la 4.5 comparten la misma implementación, la 4.5 es la que crea hoy un nodo nuevo y la serie conserva la 4.2 del Día 38, con los mismos nombres de parámetros. También se contrastaron executeWorkflowTrigger 1.1 (inputSource y workflowInputs), postgres 2.6, stopAndError 1 y las cabeceras de la API de Claude. El servicio de Docker no estaba disponible al escribir la entrada, de modo que los JSON de los bloques 5 y 7 tienen la estructura verificada contra la documentación, revisado en octubre de 2026: no son la exportación de una instancia real y deben contrastarse con una exportación propia de la versión 2.43.7. FacturaFácil es un servicio ficticio; su API se describe con las convenciones habituales de las API REST de facturación.

Día 40. El nodo HTTP Request: integrar servicios que no tienen conector

Introducción


Ayer, en el Día 39 (Anatomía de un fallo real II: el agente que prometió un pastel imposible), Valeria reconstruyó con las trazas del historial de n8n cómo el agente de pedidos prometió un pastel que el horno no podía sacar a tiempo, encontró la causa raíz en el diseño y no en el modelo, y Trigo Lento ganó tres barreras en capas para su agente y un conjunto de regresión de cuarenta conversaciones. Con el agente bajo control, la panadería puede volver a un pendiente que lleva semanas en la libreta y que no tiene nada de inteligencia artificial: la factura de los sábados.

Es jueves 12 de marzo, a las cinco de la tarde. Marisol Peña, dueña de la Cafetería Brújula, llama por segunda vez en el mes. Trigo Lento le entrega $40$ baguettes cada mañana de lunes a sábado y le factura cada semana, y la factura de la semana pasada llegó el martes, con dos días de retraso y con el total escrito a mano en el correo. “Valeria, yo cierro mi contabilidad los sábados en la tarde. Si la factura no está el sábado, mi contador la registra el mes siguiente y se me descuadra todo. Y, por favor, que el total del correo sea el mismo de la factura; la vez pasada decía 9 210 y la factura decía 9 120.” No pide nada extraordinario: una factura correcta, a tiempo y una sola vez.

Valeria factura con FacturaFácil, un servicio en línea que emite los comprobantes fiscales y los envía por correo. Lo eligió por precio, y hasta ahora lo usaba a mano: abría la página, capturaba los seis renglones de la semana, revisaba el total y pulsaba “Emitir”. Busca FacturaFácil en el panel de nodos de n8n y no aparece. Busca en los nodos de la comunidad y tampoco. Lo único que encuentra es una página de documentación para programadores con una lista de direcciones que empiezan por https://api.facturafacil.example/v1/ y una frase al principio: “Autentique cada petición con la cabecera X-API-Key”.

Don Ernesto, que escucha la llamada desde la mesa de amasado, resume el problema a su manera: “Si el programa ese no trae el botón de FacturaFácil, entonces no sirve para facturar. ¿O me va a decir que hay que pagarle a un programador para que lo haga? Porque la última vez que contraté a alguien para la computadora me cobró tres mil pesos por instalar la impresora. Y otra cosa: si se equivoca y le manda dos facturas a Marisol, a ver quién se las cancela.” Las dos objeciones son buenas. La primera tiene una respuesta que sorprende: casi cualquier servicio con una página de documentación como esa se puede integrar sin programador, con un solo nodo. La segunda, la de la factura doble, es la que más trabajo va a costar, y la respuesta no está en el nodo, sino en una cabecera.

En la presente entrada, descubrirá por qué toda API es una conversación HTTP y cómo se traduce su documentación a las partes del nodo HTTP Request: método, URL, parámetros de consulta, cabeceras, cuerpo y códigos de estado. Configurará la autenticación con credenciales genéricas, importará una petición desde cURL y limpiará las cabeceras de autenticación que trae pegadas. Recorrerá una API paginada por cursor con un cliente que respeta Retry-After y medirá cuánto tarda descargar mil registros según el tamaño de página, con y sin límite de tasa. Llamará a Claude con el nodo HTTP Request, cuando el subnodo de modelo no ofrece lo que hace falta, y con requests en Python. Finalmente, leerá, revisará y dibujará el sub-workflow TL Facturación, calculará la factura de Brújula en centavos, aplicará una política por código de estado a quinientas respuestas simuladas y comprobará con un año simulado por qué una clave de idempotencia evita las facturas dobles.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Leer la documentación de una API REST y traducirla a una petición: método, URL, parámetros, cabeceras, cuerpo y códigos de estado.
  • Autenticar con credenciales genéricas de n8n (cabecera, OAuth2) sin dejar secretos en los parámetros.
  • Recorrer resultados paginados (página, cursor, enlace) y respetar límites de tasa con Retry-After.
  • Llamar a Claude con HTTP Request y construir el sub-workflow de facturación semanal de la Cafetería Brújula.

Cuando falta un conector


Un conector (en n8n, un nodo de servicio, como el de Google Sheets o el de Postgres) es una petición HTTP con formulario. Detrás de cada campo que usted rellena en el nodo de Google Sheets hay una dirección, un método y un cuerpo JSON que alguien en n8n escribió una vez para que nadie más tuviera que hacerlo. Cuando el servicio no tiene conector, el trabajo no desaparece: se hace a mano, con el nodo HTTP Request, que es el nodo de servicio genérico. Todo lo que un conector hace por usted (poner la dirección correcta, firmar la petición, leer la respuesta, pedir la página siguiente) el nodo HTTP Request lo hace también, pero hay que decírselo.

Esa idea viene de lejos. En el año 2000, Roy Fielding describió en su tesis doctoral un estilo de arquitectura para sistemas en red que llamó REST (o REST, por sus siglas en inglés: “Representational State Transfer”): los servicios exponen recursos con una dirección propia (una factura, una lista de comprobantes), y los clientes los leen o los modifican con un conjunto pequeño de verbos de HTTP, siempre los mismos, sin estado guardado entre una petición y otra. La consecuencia práctica, veintiséis años después, es la que salva a Valeria: si un servicio publica su API en estilo REST, la forma de hablar con él es la misma que con cualquier otro. Cambian las direcciones y los nombres de los campos; la gramática no.

Hay tres razones por las que un negocio pequeño acaba usando el nodo HTTP Request más de lo que espera:

  • El servicio es local o de nicho. Los proveedores de facturación electrónica mexicanos, los sistemas de punto de venta de una región o el servicio de mensajería de la colonia rara vez tienen nodo propio, y casi siempre tienen API.
  • El conector existe, pero se queda corto. El nodo de un servicio puede no ofrecer la operación que hace falta, o no ofrecer todavía el modelo más reciente, como pasó el Día 37 con el selector de modelos del subnodo de Anthropic.
  • Se quiere control fino. Con el nodo HTTP Request se decide qué cabeceras viajan, cuánto se espera, qué se hace con cada código de estado y cómo se reintenta, cosas que un conector a veces esconde.

El precio de esa libertad es que los errores también son suyos. Un conector bien hecho ya sabe que la API devuelve como mucho cien registros por página o que hay que esperar cuando responde 429; con el nodo HTTP Request hay que saberlo y configurarlo. Por eso el día se organiza como una lectura de la documentación de FacturaFácil, sección por sección, y cada sección se prueba primero contra una API simulada en Python, donde los errores no cuestan facturas. El primer bloque fija las importaciones, la semilla global $2026$ de la serie y los supuestos de la API ficticia: un máximo de $100$ registros por página y un límite de $60$ peticiones por minuto con ráfagas de hasta $10$:

python
# Importaciones base, semilla de la serie y supuestos del día en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import math
import re
import sqlite3
import textwrap
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon

# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
carpeta = Path('trigo_lento') / 'dia_40'
carpeta.mkdir(parents = True, exist_ok = True)

# Supuestos del servicio de facturación ficticio y precios de la Tabla A (revisado en octubre de 2026):
BASE = 'https://api.facturafacil.example/v1'   # FacturaFácil es ficticio; el dominio .example no existe
LIMITE_MAX = 100              # registros por página que acepta la API
CAPACIDAD, RITMO = 10, 1.0    # cubeta de 10 peticiones que se rellena a 1 petición por segundo
LATENCIA, T_REGISTRO = 0.30, 0.002   # segundos por petición y por registro devuelto
PRECIOS = {'claude-haiku-5-5': (0.10, 0.50), 'claude-opus-5-5': (4.00, 20.00)}
def usd(modelo, entrada, salida):
    p_ent, p_sal = PRECIOS[modelo]
    return entrada * p_ent / 1e6 + salida * p_sal / 1e6
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'API ficticia: {BASE} | página máxima {LIMITE_MAX} | {RITMO * 60:.0f} peticiones por minuto, ráfaga de {CAPACIDAD}')
numpy 2.4.6 | pandas 3.0.3
API ficticia: https://api.facturafacil.example/v1 | página máxima 100 | 60 peticiones por minuto, ráfaga de 10

La API ficticia queda descrita en dos líneas: $100$ registros por página como máximo y $60$ peticiones por minuto, con una ráfaga inicial de $10$. Son valores habituales en las API de servicios pequeños, y los dos tendrán consecuencias medibles antes de que termine el día: el primero decide cuántas peticiones hacen falta para leer todo, y el segundo, cuánto hay que esperar entre ellas.

Anatomía de una petición REST


La documentación de FacturaFácil describe cada operación con el mismo esquema: un verbo, una ruta, unos parámetros, unas cabeceras, un cuerpo de ejemplo y una lista de respuestas posibles. Ese esquema es la petición HTTP desarmada, y la Figura 1 la presenta con una metáfora que conviene tener en mente: una petición HTTP es una carta.

Una petición HTTP es una carta: el sobre lleva el método y la URL, las señas adicionales son los parámetros de consulta, el remitente se acredita con una credencial, el contenido es el cuerpo JSON y el acuse de recibo es el código de estado.

Figura 1. Una petición HTTP es una carta: el sobre lleva el método y la URL, las señas adicionales son los parámetros de consulta, el remitente se acredita con una credencial, el contenido es el cuerpo JSON y el acuse de recibo es el código de estado.

La carta tiene cinco partes, y el nodo HTTP Request tiene un campo para cada una:

  • El sobre: método y URL. El método dice qué se quiere hacer con el recurso y la URL dice cuál es. GET https://api.facturafacil.example/v1/facturas pide la lista de facturas; POST a la misma dirección pide crear una nueva. Los métodos que usará la serie son GET (leer), POST (crear), PUT y PATCH (reemplazar o modificar) y DELETE (borrar). La norma de HTTP, el RFC 9110, define además dos propiedades que importan mucho al automatizar: un método es seguro si no cambia nada en el servidor (GET) e idempotente si repetirlo tiene el mismo efecto que hacerlo una vez (GET, PUT y DELETE, pero no POST). Retenga la última frase: es la raíz del problema de la factura doble.
  • Las señas adicionales: parámetros de consulta. Lo que va después del signo de interrogación en la URL (?cliente=brujula&limit=100) filtra o pagina lo que se pide. En el nodo se escriben uno por uno en Query Parameters, y n8n los codifica.
  • El remitente acreditado: cabeceras de autenticación. Una cabecera como X-API-Key o Authorization dice quién escribe y prueba que tiene permiso. Es la parte que nunca debe quedar escrita en el workflow, y la siguiente sección le dedica un apartado entero.
  • El contenido: el cuerpo. En POST, PUT y PATCH, el cuerpo lleva los datos, casi siempre en JSON, con la cabecera Content-Type: application/json que el nodo pone por usted.
  • El acuse de recibo: el código de estado. La respuesta trae un número de tres cifras que resume qué pasó: los 2xx dicen que salió bien, los 4xx que el error es del cliente (una petición mal hecha, una clave equivocada, un recurso que no existe) y los 5xx que el error es del servidor. El código 429, “demasiadas peticiones”, definido en el RFC 6585, es un caso aparte: la petición estaba bien, pero llegó demasiado pronto.

La Tabla 1 junta las cinco partes con su campo en el nodo HTTP Request y con el nombre que tiene ese campo en el JSON exportado, que es lo que se revisará en los bloques del día.

Tabla 1. Partes de una petición y dónde se configuran en el nodo HTTP Request (nombres internos revisados en octubre de 2026).

Parte de la peticiónEjemplo en FacturaFácilCampo del nodoNombre en el JSON exportado
MétodoPOSTMethodmethod (por omisión GET)
URLhttps://api.facturafacil.example/v1/facturasURLurl
Parámetros de consultacliente=brujula, limit=100Send Query ParameterssendQuery, queryParameters
CabecerasIdempotency-Key: brujula-2026-03-09Send HeaderssendHeaders, headerParameters
AutenticaciónX-API-Key desde una credencialAuthenticationauthentication, genericAuthType, credentials
CuerpoJSON con cliente, renglones y totalSend Body, Body Content TypesendBody, specifyBody, jsonBody
Código de estado201, 422, 429, 503Options, Responseoptions.response.response.fullResponse y neverError
Tiempo máximo de espera$30$ segundosOptions, Timeoutoptions.timeout (por omisión $10,000$ ms)

Dos opciones de la última parte de la tabla cambian por completo el comportamiento del nodo, y conviene entenderlas desde ahora. Por omisión, el nodo solo devuelve el cuerpo de la respuesta y trata como error cualquier código que no sea 2xx: la ejecución se detiene en el nodo con un mensaje rojo. Con Include Response Headers and Status (fullResponse), el nodo devuelve además el código y las cabeceras, que hacen falta para leer Retry-After. Con Never Error (neverError), el nodo da por buena cualquier respuesta, y la decisión de qué hacer con un 422 o un 503 pasa al workflow, a un nodo IF que lee statusCode. El sub-workflow de facturación usará las dos, porque la política de la panadería distingue entre errores que se reintentan y errores que se avisan.

Autenticación en el nodo


La autenticación es el punto en el que más se equivoca quien empieza con el nodo HTTP Request, y casi siempre de la misma forma: escribe la clave en una cabecera de la petición, la petición funciona y la clave queda guardada en el workflow para siempre. Desde ahí viaja a cada exportación, a cada copia de seguridad del Día 35 y a cada repositorio donde se guarde el JSON. El nodo ofrece dos caminos para no hacerlo:

  • Tipo de credencial predefinido (predefinedCredentialType): si n8n ya tiene una credencial para ese servicio (por ejemplo, la de Anthropic que el Día 37 creó para el subnodo de modelo), el nodo HTTP Request puede reutilizarla aunque el servicio no tenga un nodo para la operación que usted necesita. La documentación de n8n lo recomienda siempre que exista.
  • Credencial genérica (genericCredentialType): para servicios que n8n no conoce. Usted elige el mecanismo (genericAuthType) y n8n guarda el secreto cifrado con la N8N_ENCRYPTION_KEY, igual que cualquier otra credencial.

Los mecanismos genéricos que ofrece el nodo, según la documentación y el código fuente revisados en octubre de 2026, son estos, con el nombre interno entre paréntesis:

  • Header Auth (httpHeaderAuth): una cabecera con nombre y valor, como X-API-Key: <clave>. Es el caso de FacturaFácil y de la API de Claude, que espera la clave en la cabecera x-api-key.
  • Bearer Auth (httpBearerAuth): la cabecera Authorization: Bearer <token>, muy común en las API modernas.
  • Basic Auth (httpBasicAuth): usuario y contraseña codificados en la cabecera Authorization. Es lo que usa Twilio, y lo que el Día 38 configuró para enviar mensajes de WhatsApp.
  • Query Auth (httpQueryAuth): la clave como parámetro de la URL. Funciona, pero las URL acaban en registros de servidores y de proxies, así que la serie solo la usa cuando el servicio no ofrece otra cosa.
  • OAuth2 API (oAuth2Api) y OAuth1 API (oAuth1Api): el servicio entrega un token de acceso de vida corta tras una autorización, y n8n se encarga de pedirlo, guardarlo y renovarlo. Es lo que usarán Gmail y Slack en el módulo 5.
  • Digest Auth, Custom Auth y una variante simplificada de esta última, para casos raros en los que el servicio combina cabeceras, parámetros y cuerpo.

Para FacturaFácil, Valeria crea una credencial de tipo Header Auth que se llama FacturaFácil x-api-key TL, con el nombre de cabecera X-API-Key y la clave que le dio el servicio. En el JSON exportado del workflow, el nodo solo guarda una referencia: "credentials": {"httpHeaderAuth": {"id": "0000000000000041", "name": "FacturaFácil x-api-key TL"}}. El secreto no aparece. La credencial de Claude es otra, Claude x-api-key TL, con la cabecera x-api-key, y su gasto queda protegido por el tope de $8$ USD al mes que la panadería fijó en la consola de Claude para su rubro.

Hay una forma muy rápida de configurar un nodo HTTP Request, y es también la forma más rápida de filtrar una clave: importar desde cURL. cURL es una herramienta de línea de comandos para hacer peticiones HTTP, y casi todas las documentaciones de API traen sus ejemplos escritos como un comando curl. El nodo tiene un botón, Import cURL, que lee ese comando y rellena método, URL, cabeceras y cuerpo. La documentación de n8n advierte dos cosas: la importación sobrescribe la configuración que tuviera el nodo y convierte todos los valores en texto. Y hay una tercera que no dice: si el ejemplo que usted copió de su propia terminal llevaba su clave en -H "X-API-Key: ...", ahora la clave vive en los parámetros del nodo. La regla A.4.3 de la serie lo resume en una línea: después de importar desde cURL, se borran las cabeceras de autenticación y se elige una credencial. El Bloque 8 comprobará esa regla sobre el JSON exportado.

Atención aquí: una clave de API escrita en headerParameters, en queryParameters o dentro de jsonBody no está cifrada: queda en texto plano en la base de n8n, en cada exportación del workflow, en el historial de versiones y en cualquier repositorio donde se guarde el JSON, y la exportación con n8n export:workflow no la oculta. Si alguna vez una clave llega a un workflow, no basta con borrarla del nodo: revóquela en el servicio, cree una nueva y guárdela en una credencial. En los ejemplos de la serie, cualquier valor con forma de clave lleva el texto EJEMPLO-NO-REAL.

Paginación


La primera operación que Valeria necesita no es emitir, sino leer: antes de crear la factura del sábado, el sub-workflow debe comprobar si ya existe una con la misma referencia, por si el workflow semanal se ejecutó dos veces. La documentación de FacturaFácil dice que GET /facturas devuelve “una página de resultados” y que “el número máximo de resultados por página es 100”. Brújula lleva un año comprando, y Trigo Lento ha emitido más de mil comprobantes; ninguna petición los trae todos. Hay que paginar: pedir una página, leer en la respuesta cómo pedir la siguiente y repetir hasta que no queden.

Hay tres maneras de paginar:

  • Por página (o por desplazamiento): la petición dice qué página quiere (?page=3&per_page=100) o desde qué registro (?offset=200&limit=100). Es la más fácil de entender, pero tiene un defecto: si entre una petición y la siguiente se crea o se borra un registro, los demás se desplazan y uno puede saltarse o repetirse.
  • Por cursor: la respuesta trae un marcador opaco ("next_cursor": "cur_000120") que la petición siguiente devuelve tal cual. El servidor sabe dónde se quedó, y las altas y bajas intermedias no desplazan nada. Es la que usa FacturaFácil.
  • Por enlace: la respuesta trae la URL completa de la página siguiente, en el cuerpo o en la cabecera Link con rel="next", como describe el RFC 8288. El cliente no construye nada; solo sigue el enlace hasta que no hay más.

El nodo HTTP Request tiene una opción de paginación que cubre los tres casos con dos modos. En Update a Parameter in Each Request (updateAParameterInEachRequest), cada petición cambia un parámetro de consulta, de cabecera o de cuerpo con una expresión; para el cursor de FacturaFácil, el parámetro cursor toma el valor {{ $response.body.next_cursor }}, y para la paginación por página, page toma {{ $pageCount + 1 }}. En Response Contains Next URL (responseContainsNextURL), la expresión da la URL completa de la página siguiente. Las variables $response (con body, headers y statusCode), $pageCount (páginas ya descargadas) y $request (la petición enviada) solo existen dentro de esa opción. Falta decirle al nodo cuándo parar (paginationCompleteWhen): cuando la respuesta viene vacía, cuando llega un código concreto o cuando una expresión se cumple; FacturaFácil marca el final con "has_more": false, así que la condición es {{ !$response.body.has_more }}.

Antes de configurar el nodo, Valeria prueba la conversación en Python. El siguiente bloque escribe la API ficticia con la función simular_get(url, params, rng) que la serie usa para las llamadas HTTP simuladas: devuelve un objeto con status_code, headers y json(), pagina por cursor sobre $1,000$ comprobantes, falla con un 503 con la probabilidad que se le indique y aplica el límite de tasa con una cubeta de fichas: la cubeta guarda hasta $10$ fichas, cada petición gasta una, se rellena a una ficha por segundo y, cuando está vacía, la API responde 429 con la cabecera Retry-After, que dice cuántos segundos esperar:

python
# API ficticia de FacturaFácil: paginación por cursor, límite de tasa con Retry-After y fallos 503, en Python:
class Respuesta:
    def __init__(self, status_code, cuerpo, headers = None):
        self.status_code, self._cuerpo, self.headers = status_code, cuerpo, headers or {}
    def json(self):
        return self._cuerpo

COMPROBANTES = [{'folio': f'FF-{i:05d}', 'referencia': f'cliente{i % 37:02d}-sem{i // 37:02d}',
                 'total_centavos': int(rng.integers(5_000, 900_000))} for i in range(1, 1001)]
cubeta = {'fichas': float(CAPACIDAD), 't': 0.0}

def reiniciar_api(capacidad = CAPACIDAD):
    cubeta.update(fichas = float(capacidad), t = 0.0, capacidad = capacidad)

def simular_get(url, params, rng, ahora = 0.0, p_503 = 0.02):
    # La cubeta se rellena con el tiempo transcurrido; sin ficha, la API responde 429 con Retry-After:
    cubeta['fichas'] = min(cubeta.get('capacidad', CAPACIDAD), cubeta['fichas'] + (ahora - cubeta['t']) * RITMO)
    cubeta['t'] = ahora
    if cubeta['fichas'] < 1:
        espera = math.ceil((1 - cubeta['fichas']) / RITMO)
        return Respuesta(429, {'error': 'rate_limited'}, {'Retry-After': str(espera)})
    cubeta['fichas'] -= 1
    if rng.random() < p_503:
        return Respuesta(503, {'error': 'unavailable'})
    limite = min(int(params.get('limit', 20)), LIMITE_MAX)
    inicio = int(params['cursor'][4:]) if params.get('cursor') else 0     # cursor opaco 'cur_000120'
    datos = COMPROBANTES[inicio:inicio + limite]
    hay_mas = inicio + limite < len(COMPROBANTES)
    return Respuesta(200, {'data': datos, 'has_more': hay_mas,
                           'next_cursor': f'cur_{inicio + limite:06d}' if hay_mas else None},
                     {'X-RateLimit-Remaining': str(int(cubeta['fichas']))})

reiniciar_api()
r = simular_get(f'{BASE}/comprobantes', {'limit': 3}, rng)
print(r.status_code, json.dumps(r.json(), ensure_ascii = False)[:150], '...')
print('Cabeceras:', r.headers)
reiniciar_api(capacidad = 2)
codigos = [simular_get(f'{BASE}/comprobantes', {'limit': 3}, rng, ahora = 0.1 * i, p_503 = 0).status_code for i in range(4)]
print('Cuatro peticiones en 0.3 s con una cubeta de 2:', codigos, '| Retry-After:',
      simular_get(f'{BASE}/comprobantes', {}, rng, ahora = 0.4).headers['Retry-After'])
200 {"data": [{"folio": "FF-00001", "referencia": "cliente01-sem00", "total_centavos": 767407}, {"folio": "FF-00002", "referencia": "cliente02-sem00", "to ...
Cabeceras: {'X-RateLimit-Remaining': '9'}
Cuatro peticiones en 0.3 s con una cubeta de 2: [200, 200, 429, 429] | Retry-After: 1

La primera petición, con limit = 3, devuelve código $200$, tres comprobantes y un cursor; la cabecera X-RateLimit-Remaining dice que quedan $9$ fichas. La segunda prueba vacía a propósito una cubeta de solo dos fichas: cuatro peticiones en tres décimas de segundo dan $[200, 200, 429, 429]$, y la respuesta trae Retry-After: 1, el segundo que tarda en volver una ficha. Esa cabecera es la API diciendo con exactitud cuándo puede volver a llamar. Ignorarla es como tocar el timbre cada medio minuto cuando ya le dijeron “en cinco minutos le abro”.

Con la API simulada, el cliente es un bucle con cuatro ramas: si la respuesta es 429, espera lo que diga Retry-After y repite la misma página; si es 503, espera con una pausa que se duplica en cada fallo seguido; si es 200, guarda los registros y, si la respuesta dice que hay más, pide la página siguiente con el cursor; si no hay más, termina. El siguiente bloque descarga los $1,000$ comprobantes con $20$ por página y compara dos clientes: el que respeta Retry-After y uno impaciente que reintenta a las cinco centésimas de segundo:

python
# Cliente con paginación por cursor que respeta Retry-After y reintenta los 503 con espera exponencial, en Python:
def descargar_todo(limite, rng, respetar = True, p_503 = 0.05, tope = 2000):
    reiniciar_api()
    reloj, registros, cursor, peticiones, esperas, n429, n503, fallos_seguidos = 0.0, [], None, 0, [], 0, 0, 0
    while peticiones < tope:
        params = {'limit': limite, **({'cursor': cursor} if cursor else {})}
        r = simular_get(f'{BASE}/comprobantes', params, rng, ahora = reloj, p_503 = p_503)
        peticiones += 1
        reloj += LATENCIA
        if r.status_code == 429:
            n429 += 1
            espera = float(r.headers['Retry-After']) if respetar else 0.05   # el impaciente vuelve casi al instante
            esperas.append(espera)
            reloj += espera
            continue
        if r.status_code == 503:
            n503 += 1
            espera = min(30, 2 ** fallos_seguidos) + rng.uniform(0, 1)       # espera exponencial con variación
            fallos_seguidos += 1
            esperas.append(espera)
            reloj += espera
            continue
        fallos_seguidos = 0
        cuerpo = r.json()
        registros += cuerpo['data']
        reloj += T_REGISTRO * len(cuerpo['data'])
        if not cuerpo['has_more']:
            break
        cursor = cuerpo['next_cursor']
    return {'registros': len(registros), 'únicos': len({c['folio'] for c in registros}), 'peticiones': peticiones,
            'respuestas_429': n429, 'respuestas_503': n503, 'segundos_esperando': round(sum(esperas), 1),
            'tiempo_total_s': round(reloj, 1)}

resultados = pd.DataFrame([descargar_todo(20, rng), descargar_todo(20, rng, respetar = False)],
                          index = ['respeta Retry-After', 'reintenta sin esperar'])
print(resultados.to_string())
paginas = math.ceil(len(COMPROBANTES) / 20)
print(f'Páginas necesarias con limit = 20: {paginas}; peticiones extra del impaciente: '
      f"{resultados.loc['reintenta sin esperar', 'peticiones'] - resultados.loc['respeta Retry-After', 'peticiones']}")
                       registros  únicos  peticiones  respuestas_429  respuestas_503  segundos_esperando  tiempo_total_s
respeta Retry-After         1000    1000          68              18               0                18.0            40.4
reintenta sin esperar       1000    1000         117              66               1                 4.5            41.6
Páginas necesarias con limit = 20: 50; peticiones extra del impaciente: 49

Los dos clientes descargan los $1,000$ registros, sin uno repetido, que es lo que garantiza el cursor. La diferencia está en cómo lo hacen. El cliente que respeta Retry-After hace $68$ peticiones: las $50$ páginas más $18$ respuestas 429, y espera $18$ segundos en total. El impaciente hace $117$, con $66$ respuestas 429, es decir, $49$ peticiones más, y no termina antes: $41.6$ segundos frente a $40.4$. No puede terminar antes, porque el límite lo pone la cubeta del servidor, no la paciencia del cliente. Lo único que consigue el impaciente es gastar peticiones, y en una API real muchos servicios castigan esa insistencia con bloqueos temporales de la clave. Respetar Retry-After no hace la descarga más lenta; la hace igual de rápida con menos ruido.

La Figura 2 resume las tres maneras de paginar con el ejemplo de FacturaFácil.

Tres maneras de paginar: por página (sencilla, pero se desplaza si cambian los datos), por cursor (el servidor recuerda dónde quedó; la usa FacturaFácil) y por enlace (la respuesta trae la URL siguiente).

Figura 2. Tres maneras de paginar: por página (sencilla, pero se desplaza si cambian los datos), por cursor (el servidor recuerda dónde quedó; la usa FacturaFácil) y por enlace (la respuesta trae la URL siguiente).

Límites de tasa


El cliente del bloque anterior pedía $20$ registros por página. ¿Qué pasa si pide más? Con menos páginas hay menos peticiones, y con menos peticiones la cubeta se vacía menos. Con páginas más grandes, cada respuesta tarda un poco más en llegar, porque trae más datos. El tiempo total para descargar $n$ registros con páginas de $b$ registros se puede escribir así:

$$ T(b) = \max\left( \left\lceil \frac{n}{b} \right\rceil (\ell + b,t),; \frac{\lceil n/b \rceil - C}{\rho} \right) $$

La lógica detrás de esta fórmula es múltiple:

  • $\lceil n/b \rceil$ es el número de páginas, y por tanto de peticiones mínimas: $50$ con $b = 20$ y $10$ con $b = 100$.
  • El primer término es el tiempo sin límite de tasa: cada petición cuesta una latencia fija $\ell$ ($0.30$ segundos) más $t$ ($0.002$ segundos) por registro devuelto. Crece con el número de páginas, porque cada página paga su latencia.
  • El segundo término es el límite: las primeras $C$ peticiones ($10$) salen de la ráfaga, y las demás tienen que esperar a que la cubeta se rellene a $\rho$ ($1$) petición por segundo. Si el número de páginas no supera la ráfaga, el término es negativo y no cuenta.
  • En conclusión, el tiempo total lo decide el más lento de los dos frenos, y los dos se aflojan con páginas más grandes, hasta el máximo que acepte la API.

El siguiente bloque calcula el tiempo con la fórmula, con y sin límite, para tamaños de página de $5$ a $100$, lo compara con el cliente simulado del Bloque 3 y dibuja las tres curvas:

python
# Tiempo total para descargar 1 000 registros frente al tamaño de página, con y sin límite de tasa, en Python:
def tiempo_teorico(b, n = 1000, limitado = True):
    paginas = math.ceil(n / b)
    sin_limite = paginas * (LATENCIA + b * T_REGISTRO)
    return max(sin_limite, (paginas - CAPACIDAD) / RITMO) if limitado else sin_limite

tamanos = [5, 10, 20, 25, 40, 50, 75, 100]
filas = []
for b in tamanos:
    con = descargar_todo(b, rng, p_503 = 0)
    filas.append({'limit': b, 'peticiones': con['peticiones'], 'respuestas_429': con['respuestas_429'],
                  'simulado_con_limite_s': con['tiempo_total_s'],
                  'teorico_con_limite_s': round(tiempo_teorico(b), 1),
                  'teorico_sin_limite_s': round(tiempo_teorico(b, limitado = False), 1)})
tabla_paginas = pd.DataFrame(filas).set_index('limit')
print(tabla_paginas.to_string())
print('Mejora de limit = 100 frente a limit = 10:',
      round(tabla_paginas.loc[10, 'simulado_con_limite_s'] / tabla_paginas.loc[100, 'simulado_con_limite_s'], 1), 'veces')

# Tiempo frente a tamaño de página:
fig, ax = plt.subplots(figsize = (8, 4.5))
ax.plot(tamanos, tabla_paginas['teorico_sin_limite_s'], color = '#2a78d6', marker = 'o', label = 'Sin límite de tasa (fórmula)')
ax.plot(tamanos, tabla_paginas['teorico_con_limite_s'], color = '#eb6834', linestyle = '--', label = 'Con 60 peticiones por minuto (fórmula)')
ax.plot(tamanos, tabla_paginas['simulado_con_limite_s'], color = '#7b4bb7', marker = 's', linestyle = '', label = 'Con límite (simulación)')
ax.axvline(LIMITE_MAX, color = '#8a8986', linestyle = ':')
ax.text(LIMITE_MAX - 2, ax.get_ylim()[1] * 0.9, 'máximo de la API', ha = 'right', fontsize = 9, color = '#52514e')
ax.set_xlabel('Registros por página (limit)')
ax.set_ylabel('Segundos para 1 000 registros')
ax.set_yscale('log')
ax.set_title('Tiempo de descarga frente al tamaño de página', fontsize = 16)
ax.legend(fontsize = 9)
plt.tight_layout()
plt.show()
       peticiones  respuestas_429  simulado_con_limite_s  teorico_con_limite_s  teorico_sin_limite_s
limit
5             299              99                  190.7                 190.0                  62.0
10            145              45                   90.5                  90.0                  32.0
20             68              18                   40.4                  40.0                  17.0
25             53              13                   30.9                  30.0                  14.0
40             30               5                   16.0                  15.0                   9.5
50             22               2                   10.6                  10.0                   8.0
75             14               0                    6.2                   6.3                   6.3
100            10               0                    5.0                   5.0                   5.0
Mejora de limit = 100 frente a limit = 10: 18.1 veces

Gráfica: Límites de tasa. Tiempo total para descargar 1 000 registros frente al tamaño de página, con y sin límite de tasa

La simulación coincide con la fórmula con un error de menos de un segundo en todos los tamaños, y el resultado es claro. Con $10$ registros por página, la descarga tarda $90.5$ segundos: $145$ peticiones, $45$ de ellas rechazadas con 429, y casi todo el tiempo es espera de la cubeta, porque sin límite habría tardado $32$. Con $100$ por página, tarda $5.0$ segundos, $10$ peticiones y ningún rechazo: $18.1$ veces más rápido. En la gráfica, la curva naranja del límite y la azul sin límite se juntan a partir de unos $75$ registros por página, que es donde el número de páginas cae por debajo de lo que la ráfaga y el ritmo de la cubeta permiten. La regla práctica es directa: pida siempre el tamaño de página máximo que documente la API, salvo que cada registro sea muy pesado. El nodo de la facturación pide limit=100.

Leer bien es la mitad del problema. La otra mitad es qué hacer cuando la respuesta no es un 200. La política de Trigo Lento clasifica los códigos en tres grupos y les da una acción a cada uno, como resume la Tabla 2.

Tabla 2. Códigos de estado y política de Trigo Lento para las API sin conector.

CódigosSignificadoAcciónPor qué
200, 201Correcto, recurso creadoSeguirLa petición hizo lo que se pidió
400, 422Petición mal formada o datos inválidosDetener y avisarRepetirla dará el mismo error; hay que corregir los datos
401, 403Sin credencial válida o sin permisoDetener y avisarLa clave caducó, se revocó o no tiene el permiso; lo arregla una persona
404El recurso no existeDetener y avisarURL equivocada o recurso borrado
429Demasiadas peticionesEsperar Retry-After y repetirLa petición era buena; llegó antes de tiempo
500, 502, 503, 529Error o sobrecarga del servidorReintentar con espera exponencialSuele ser pasajero; insistir de inmediato lo empeora

Dos detalles de la tabla. El código 529 no es estándar: es el que usa la API de Claude cuando está sobrecargada (overloaded_error), y se trata igual que un 503. Y el 422 aparece aparte del 400 porque es el que devuelven muchas API cuando el JSON está bien formado pero los datos no pasan sus reglas, como una factura con un total que no coincide con la suma de sus renglones, que es exactamente el error que Marisol no quiere volver a ver. La Figura 3 convierte la tabla en un semáforo.

El semáforo de los códigos de estado: verde, 2xx, seguir (425 de 500); ámbar, 4xx, detener y avisar sin reintentar (27); rojo, 429 y 5xx, esperar y reintentar (48, de las que 46 se recuperaron).

Figura 3. El semáforo de los códigos de estado: verde, 2xx, seguir (425 de 500); ámbar, 4xx, detener y avisar sin reintentar (27); rojo, 429 y 5xx, esperar y reintentar (48, de las que 46 se recuperaron).

Para los errores del servidor, la espera no es fija: se duplica en cada intento fallido, con un tope y una pequeña variación aleatoria. Esa es la espera exponencial con variación (en inglés, exponential backoff with jitter):

$$ w_k = \min\left(w_{max},; w_0 \cdot 2^{k}\right) + U(0, 1), \qquad P(\text{agotar}) = (1 - q)^{K - 1} $$

La lógica detrás de esta fórmula es múltiple:

  • $w_k$ es la espera antes del intento $k + 1$; con $w_0 = 1$ segundo, las esperas son $2$, $4$ y $8$ segundos, y nunca más de $w_{max} = 30$.
  • $U(0, 1)$ es un segundo aleatorio que se suma para que, si muchos clientes fallaron a la vez, no vuelvan todos en el mismo instante y tumben de nuevo al servidor.
  • Si cada reintento sale bien con probabilidad $q$ y se permiten $K$ intentos en total, la probabilidad de agotarlos todos es $(1 - q)^{K-1}$: con $q = 0.7$ y $K = 4$, $0.3^3 = 0.027$, menos de tres de cada cien errores pasajeros.
  • En conclusión, tres reintentos bien espaciados convierten un fallo pasajero en un retraso de segundos, y lo que sobrevive a los tres ya no es pasajero: se avisa.

El nodo HTTP Request ofrece tres herramientas para todo esto, y conviene saber qué hace cada una y qué no:

  1. En primer lugar, la paginación tiene su propio intervalo (requestInterval, en milisegundos) y un tope de páginas (limitPagesFetched y maxRequests). Con FacturaFácil, un intervalo de $1,000$ ms mantiene el ritmo de la cubeta sin depender de la ráfaga, y un tope de $20$ páginas evita un bucle infinito si la API devolviera siempre has_more: true.
  2. En segundo lugar, el procesamiento por lotes (batching, con batchSize y batchInterval) controla cuántos items de entrada se envían a la vez y cuánto se espera entre lotes. Sirve cuando el nodo recibe muchos items, uno por petición; el Día 41 lo usará con miles.
  3. En tercer lugar, la opción Retry On Fail de la pestaña de ajustes del nodo (retryOnFail, maxTries, waitBetweenTries) repite la petición cuando el nodo falla, con una espera fija. No lee Retry-After ni distingue un 503 de un 422: reintenta cualquier fallo. Por eso la serie la usa solo para fallos de conexión y tiempos agotados, y la política de la Tabla 2 se implementa con neverError, un IF que lee el código y un nodo Wait, como se verá en el sub-workflow.

Claude por HTTP Request


La regla A.4.3 de la serie dice que, si el subnodo de modelo de Anthropic no ofrece el modelo que hace falta, se usa el nodo HTTP Request contra la API de Claude. Hay otras dos situaciones en las que conviene hacerlo aunque el subnodo funcione. La primera, cuando la tarea es una sola llamada que no necesita cadena, memoria ni analizador: el clúster de IA del Día 37 es más de lo que hace falta. La segunda, cuando se necesita un parámetro de la API que el formulario del subnodo todavía no expone, como el respaldo del lado del servidor que la serie activa en los ejemplos con Opus 5.5.

La tarea del día es pequeña: cuando FacturaFácil confirma la factura, Claude redacta la nota breve que la acompañará en el correo a Brújula, a partir del folio, la referencia y el total. Valeria la revisará antes de enviarla, como manda la regla de humano al mando, y el módulo 5 la convertirá en un borrador de Gmail. ¿Por qué usar un modelo para dos frases? Porque la nota de la semana pasada se escribió a mano y tenía el total equivocado; generada a partir de los mismos datos de la factura, la cifra no se transcribe, se copia. Y, por si el modelo se equivocara, la instrucción le prohíbe cambiar importes, y la revisión de Valeria es la última barrera.

El nodo tiene las cinco partes de la carta. El método es POST y la URL, https://api.anthropic.com/v1/messages. La autenticación es la credencial genérica Claude x-api-key TL, de tipo Header Auth. Las cabeceras adicionales son dos: anthropic-version: 2023-06-01, obligatoria en todas las peticiones, y anthropic-beta: server-side-fallback-2026-07-01, que activa el respaldo del lado del servidor. El cuerpo es el mismo JSON que enviaría el SDK de Python: model, max_tokens, system, messages, output_config con el esfuerzo en low y fallbacks en "default", sin temperature ni top_p. Así queda el nodo exportado:

json
{
  "parameters": {
    "method": "POST",
    "url": "https://api.anthropic.com/v1/messages",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpHeaderAuth",
    "sendHeaders": true,
    "headerParameters": {"parameters": [{"name": "anthropic-version", "value": "2023-06-01"}, {"name": "anthropic-beta", "value": "server-side-fallback-2026-07-01"}]},
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify({model: 'claude-opus-5-5', max_tokens: 1024, fallbacks: 'default', output_config: {effort: 'low'}, system: 'Usted redacta la nota breve que acompaña la factura semanal de un cliente mayorista de la panadería Trigo Lento. Use solo los datos de la etiqueta factura, en tono cordial y de usted, en no más de 80 palabras. No prometa descuentos, no cambie importes ni fechas y no añada datos que no estén en la factura.', messages: [{role: 'user', content: '<factura>' + JSON.stringify({folio: $json.body.folio, referencia: $('Armar factura').item.json.referencia, total_mxn: $('Armar factura').item.json.total_centavos / 100, dias: $('Armar factura').item.json.renglones.length}) + '</factura>'}]}) }}",
    "options": {"timeout": 60000, "response": {"response": {"fullResponse": true, "neverError": true}}}
  },
  "id": "4a400000-0000-4000-8000-000000000012",
  "name": "Redactar nota con Claude",
  "type": "n8n-nodes-base.httpRequest",
  "typeVersion": 4.2,
  "position": [1760, 120],
  "credentials": {"httpHeaderAuth": {"id": "0000000000000040", "name": "Claude x-api-key TL"}}
}

El cuerpo es una expresión que construye el JSON con JSON.stringify, en lugar de escribirlo campo a campo. Es la forma más segura de meter datos variables en un cuerpo JSON: si el folio o el texto trajeran comillas, JSON.stringify las escapa, mientras que pegar el valor entre comillas a mano rompería el JSON en el primer apóstrofo. Los datos de la factura viajan dentro de una etiqueta <factura>, como dato y no como instrucción, y solo viajan los cuatro campos que la nota necesita: folio, referencia, total y número de días. El RFC de Brújula y el correo de Marisol no salen hacia el modelo, porque no hacen falta para redactar dos frases. Las opciones fijan un tiempo máximo de $60$ segundos y piden la respuesta completa sin error automático, para que el workflow lea el código y el stop_reason.

El modelo es claude-opus-5-5, el predeterminado de la serie, y el coste no justifica bajar a otro: con unos $650$ tokens de entrada y $180$ de salida, cada nota cuesta $0.0062$ USD, y cuatro al mes, $0.0248$ USD, como calculará el Bloque 9. Con cuatro llamadas al mes, la diferencia con Haiku 5.5 no llega a tres centavos de dólar, y la redacción es justo la tarea en la que conviene el modelo más cuidadoso.

Por qué el nodo HTTP Request y no el subnodo Anthropic Chat Model en esta tarea: la nota es una sola llamada sin memoria ni herramientas, el formulario del subnodo en la versión 1.6 no expone el parámetro fallbacks del respaldo del lado del servidor, y el nodo HTTP Request permite leer el código de estado y el stop_reason en el propio workflow. El cuerpo es el mismo JSON que documenta la API de Claude, revisado en octubre de 2026.

Conviene tener la misma petición escrita en Python, por dos razones: sirve para probar la credencial y el prompt fuera de n8n, y deja ver en pocas líneas lo que el nodo hace por dentro. El siguiente bloque la escribe con requests, con la clave leída del entorno y la política de la Tabla 2 aplicada a mano: reintenta 429, 500, 502, 503 y 529 con la espera de Retry-After o la exponencial, se detiene con cualquier otro error y comprueba el stop_reason antes de leer el texto. Como Opus 5.5 piensa siempre antes de responder, la respuesta puede traer bloques de pensamiento antes del texto, así que la nota se busca por tipo y no por posición. Llama a la API y no se ejecutó para escribir la entrada; la salida es un ejemplo comentado:

python
# La misma petición del nodo HTTP Request con requests en Python, con la clave desde el entorno:
import os
import time

import requests

URL = 'https://api.anthropic.com/v1/messages'
cabeceras = {'x-api-key': os.environ['ANTHROPIC_API_KEY'],      # nunca escrita en el código
             'anthropic-version': '2023-06-01',
             'anthropic-beta': 'server-side-fallback-2026-07-01',
             'content-type': 'application/json'}
SISTEMA_NOTA = ('Usted redacta la nota breve que acompaña la factura semanal de un cliente mayorista de la '
                'panadería Trigo Lento. Use solo los datos de la etiqueta factura, en tono cordial y de usted, en no '
                'más de 80 palabras. No prometa descuentos, no cambie importes ni fechas y no añada datos que no '
                'estén en la factura.')
factura = {'folio': 'FF-01207', 'referencia': 'brujula-2026-03-09', 'total_mxn': 9120.0, 'dias': 6}
cuerpo = {'model': 'claude-opus-5-5', 'max_tokens': 1024, 'fallbacks': 'default',
          'output_config': {'effort': 'low'},                   # sin temperature ni top_p
          'system': SISTEMA_NOTA,
          'messages': [{'role': 'user', 'content': f'<factura>{factura}</factura>'}]}

for intento in range(4):
    r = requests.post(URL, headers = cabeceras, json = cuerpo, timeout = 60)
    if r.status_code == 200:
        break
    if r.status_code in (429, 500, 502, 503, 529) and intento < 3:
        espera = float(r.headers.get('retry-after', min(30, 2 ** (intento + 1))))
        print(f'{r.status_code}: espero {espera:.0f} s')
        time.sleep(espera)
        continue
    raise SystemExit(f'Detener y avisar: {r.status_code} {r.text[:200]}')

datos = r.json()
if datos['stop_reason'] != 'end_turn':
    raise SystemExit(f"Revisión humana: stop_reason = {datos['stop_reason']}")
nota = next(b['text'] for b in datos['content'] if b['type'] == 'text')
uso = datos['usage']
print('Modelo:', datos['model'], '| request-id:', r.headers.get('request-id'))
print(nota)
print(f"Tokens: {uso['input_tokens']} de entrada y {uso['output_tokens']} de salida | "
      f"{uso['input_tokens'] * 4 / 1e6 + uso['output_tokens'] * 20 / 1e6:.6f} USD")

# Salida de ejemplo (puede variar):
# Modelo: claude-opus-5-5 | request-id: req_EJEMPLO-NO-REAL-0000
# Buen día. Le enviamos la factura FF-01207, referencia brujula-2026-03-09, con las entregas de los seis
# días de la semana, por un total de 9,120.00 MXN. Si encuentra cualquier diferencia, con gusto la
# revisamos. Gracias por su preferencia. Trigo Lento.
# Tokens: 642 de entrada y 171 de salida | 0.005988 USD

En el ejemplo, la respuesta llega a la primera, con $642$ tokens de entrada, $171$ de salida y $0.005988$ USD, en línea con la estimación. La nota cita el folio, la referencia y el total de $9,120.00$ MXN sin inventar nada que no estuviera en la etiqueta, ni siquiera el nombre de Marisol, que no se le pasó. El código tiene la misma forma que el cliente simulado del Bloque 3: cambiar simular_get por requests.get, o simular_post por requests.post, es cambiar una línea, y por eso la serie prueba primero contra la simulación. Observe también que el request-id de la cabecera se imprime: es el dato que pide el soporte de la API cuando algo falla, y conviene guardarlo en el registro de ejecuciones.

El sub-workflow TL Facturación


Con las piezas probadas, Valeria arma el proceso de la factura. No lo construye como un workflow suelto, sino como un sub-workflow: un workflow que empieza con el disparador Execute Workflow Trigger (en la interfaz actual, When Executed by Another Workflow) y que solo se ejecuta cuando otro lo llama. El workflow que lo llama es pequeño y no se muestra hoy: un disparador programado los sábados a las 14:00, la hora de la Ciudad de México que fija la zona horaria de la instancia, que pasa el cliente y las fechas de la semana. Separarlo así tiene tres ventajas: el sub-workflow se prueba solo, con datos fijos; se reutiliza para cualquier otro cliente con factura periódica, y su historial de ejecuciones queda aparte del resto.

TL Facturación tiene trece nodos:

  1. Al ser llamado (executeWorkflowTrigger): declara tres entradas, cliente_id, semana_inicio y semana_fin. El modo Define using fields below (inputSource: workflowInputs) hace que el workflow que llama vea los tres campos y no pueda olvidar ninguno.
  2. Pedidos de la semana (postgres): consulta los pedidos entregados de la semana con una consulta parametrizada, $1, $2 y $3, sin pegar valores dentro del SQL.
  3. Armar factura (code): suma los renglones en centavos y construye la referencia brujula-2026-03-09, cliente más lunes de la semana.
  4. Facturas emitidas (httpRequest, GET): recorre por cursor las facturas del cliente, con limit=100, un intervalo de $1,000$ ms y un tope de $20$ páginas.
  5. Buscar referencia (code): busca en todas las páginas una factura con la misma referencia.
  6. ¿Ya existe? (IF): si existe, el sub-workflow devuelve su folio sin emitir nada.
  7. Emitir factura (httpRequest, POST): crea la factura con la cabecera Idempotency-Key igual a la referencia.
  8. ¿Emitida? (IF): 200 o 201 siguen adelante.
  9. ¿Reintentable? (IF): 429, 500, 502, 503 y 529, con menos de tres vueltas, van a esperar.
  10. Esperar Retry-After (wait): espera lo que diga la cabecera o la pausa exponencial, con un tope de $30$ segundos, y vuelve a Emitir factura.
  11. Detener y avisar (stopAndError): cualquier otro código, o un reintentable que agotó las vueltas, detiene la ejecución con un mensaje que incluye el código y la referencia; el workflow de errores de la instancia, declarado en settings.errorWorkflow, avisa a Valeria.
  12. Redactar nota con Claude (httpRequest): el nodo de la sección anterior.
  13. Devolver folio (Edit Fields): entrega al workflow que llamó el folio, el total en centavos, la nota y la marca revisar_nota si el stop_reason no fue end_turn.

Este es el JSON del sub-workflow, con los identificadores sustituidos por valores de ejemplo como manda la regla A.4.3:

json
{
  "name": "TL Facturación",
  "nodes": [
    {
      "parameters": {"inputSource": "workflowInputs", "workflowInputs": {"values": [{"name": "cliente_id", "type": "string"}, {"name": "semana_inicio", "type": "string"}, {"name": "semana_fin", "type": "string"}]}},
      "id": "4a400000-0000-4000-8000-000000000001",
      "name": "Al ser llamado",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.1,
      "position": [0, 0]
    },
    {
      "parameters": {"operation": "executeQuery", "query": "SELECT fecha_entrega, clave, cantidad, precio_centavos FROM pedidos WHERE cliente_id = $1 AND fecha_entrega BETWEEN $2 AND $3 AND estado = 'entregado' ORDER BY fecha_entrega;", "options": {"queryReplacement": "={{ [$json.cliente_id, $json.semana_inicio, $json.semana_fin] }}"}},
      "id": "4a400000-0000-4000-8000-000000000002",
      "name": "Pedidos de la semana",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [220, 0],
      "credentials": {"postgres": {"id": "0000000000000042", "name": "Postgres TL"}}
    },
    {
      "parameters": {"jsCode": "const t = $('Al ser llamado').first().json;\nconst renglones = $input.all().map(i => ({fecha: i.json.fecha_entrega, clave: i.json.clave, cantidad: i.json.cantidad, precio_centavos: i.json.precio_centavos, importe_centavos: i.json.cantidad * i.json.precio_centavos}));\nconst total = renglones.reduce((s, r) => s + r.importe_centavos, 0);\nreturn [{json: {referencia: `${t.cliente_id}-${t.semana_inicio}`, cliente_id: t.cliente_id, renglones, total_centavos: total}}];"},
      "id": "4a400000-0000-4000-8000-000000000003",
      "name": "Armar factura",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 0]
    },
    {
      "parameters": {"url": "https://api.facturafacil.example/v1/facturas", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": {"parameters": [{"name": "cliente", "value": "={{ $json.cliente_id }}"}, {"name": "limit", "value": "100"}]}, "options": {"pagination": {"pagination": {"paginationMode": "updateAParameterInEachRequest", "parameters": {"parameters": [{"type": "qs", "name": "cursor", "value": "={{ $response.body.next_cursor }}"}]}, "paginationCompleteWhen": "other", "completeExpression": "={{ !$response.body.has_more }}", "limitPagesFetched": true, "maxRequests": 20, "requestInterval": 1000}}}},
      "id": "4a400000-0000-4000-8000-000000000004",
      "name": "Facturas emitidas",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [660, 0],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "credentials": {"httpHeaderAuth": {"id": "0000000000000041", "name": "FacturaFácil x-api-key TL"}}
    },
    {
      "parameters": {"jsCode": "const ref = $('Armar factura').first().json.referencia;\nconst facturas = $input.all().flatMap(p => p.json.data ?? []);\nconst previa = facturas.find(f => f.referencia === ref);\nreturn [{json: {existe: Boolean(previa), folio: previa ? previa.folio : null, revisadas: facturas.length}}];"},
      "id": "4a400000-0000-4000-8000-000000000005",
      "name": "Buscar referencia",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 0]
    },
    {
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c4000000-0000-4000-8000-000000000001", "leftValue": "={{ $json.existe }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}},
      "id": "4a400000-0000-4000-8000-000000000006",
      "name": "¿Ya existe?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1100, 0]
    },
    {
      "parameters": {"method": "POST", "url": "https://api.facturafacil.example/v1/facturas", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendHeaders": true, "headerParameters": {"parameters": [{"name": "Idempotency-Key", "value": "={{ $('Armar factura').item.json.referencia }}"}]}, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({cliente: $('Armar factura').item.json.cliente_id, referencia: $('Armar factura').item.json.referencia, moneda: 'MXN', renglones: $('Armar factura').item.json.renglones, total_centavos: $('Armar factura').item.json.total_centavos}) }}", "options": {"timeout": 30000, "response": {"response": {"fullResponse": true, "neverError": true}}}},
      "id": "4a400000-0000-4000-8000-000000000007",
      "name": "Emitir factura",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1320, 200],
      "credentials": {"httpHeaderAuth": {"id": "0000000000000041", "name": "FacturaFácil x-api-key TL"}}
    },
    {
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c4000000-0000-4000-8000-000000000002", "leftValue": "={{ [200, 201].includes($json.statusCode) }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}},
      "id": "4a400000-0000-4000-8000-000000000008",
      "name": "¿Emitida?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1540, 200]
    },
    {
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c4000000-0000-4000-8000-000000000003", "leftValue": "={{ [429, 500, 502, 503, 529].includes($json.statusCode) && $runIndex < 3 }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}},
      "id": "4a400000-0000-4000-8000-000000000009",
      "name": "¿Reintentable?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1760, 360]
    },
    {
      "parameters": {"amount": "={{ Math.min(30, Number($json.headers['retry-after'] ?? 2 ** ($runIndex + 1))) }}", "unit": "seconds"},
      "id": "4a400000-0000-4000-8000-000000000010",
      "name": "Esperar Retry-After",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [1540, 520],
      "webhookId": "00000000-0000-4000-8000-0000000040aa"
    },
    {
      "parameters": {"errorMessage": "={{ 'FacturaFácil respondió ' + $json.statusCode + ' al emitir ' + $('Armar factura').item.json.referencia + ': ' + JSON.stringify($json.body).slice(0, 200) }}"},
      "id": "4a400000-0000-4000-8000-000000000011",
      "name": "Detener y avisar",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [1980, 360]
    },
    {
      "parameters": {"method": "POST", "url": "https://api.anthropic.com/v1/messages", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendHeaders": true, "headerParameters": {"parameters": [{"name": "anthropic-version", "value": "2023-06-01"}, {"name": "anthropic-beta", "value": "server-side-fallback-2026-07-01"}]}, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({model: 'claude-opus-5-5', max_tokens: 1024, fallbacks: 'default', output_config: {effort: 'low'}, system: 'Usted redacta la nota breve que acompaña la factura semanal de un cliente mayorista de la panadería Trigo Lento. Use solo los datos de la etiqueta factura, en tono cordial y de usted, en no más de 80 palabras. No prometa descuentos, no cambie importes ni fechas y no añada datos que no estén en la factura.', messages: [{role: 'user', content: '<factura>' + JSON.stringify({folio: $json.body.folio, referencia: $('Armar factura').item.json.referencia, total_mxn: $('Armar factura').item.json.total_centavos / 100, dias: $('Armar factura').item.json.renglones.length}) + '</factura>'}]}) }}", "options": {"timeout": 60000, "response": {"response": {"fullResponse": true, "neverError": true}}}},
      "id": "4a400000-0000-4000-8000-000000000012",
      "name": "Redactar nota con Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 120],
      "credentials": {"httpHeaderAuth": {"id": "0000000000000040", "name": "Claude x-api-key TL"}}
    },
    {
      "parameters": {"assignments": {"assignments": [{"id": "a4000000-0000-4000-8000-000000000001", "name": "folio", "value": "={{ $('Buscar referencia').item.json.existe ? $('Buscar referencia').item.json.folio : $('Emitir factura').item.json.body.folio }}", "type": "string"}, {"id": "a4000000-0000-4000-8000-000000000002", "name": "total_centavos", "value": "={{ $('Armar factura').item.json.total_centavos }}", "type": "number"}, {"id": "a4000000-0000-4000-8000-000000000003", "name": "nota", "value": "={{ $json.body?.stop_reason === 'end_turn' ? $json.body.content.find(b => b.type === 'text').text : '' }}", "type": "string"}, {"id": "a4000000-0000-4000-8000-000000000004", "name": "revisar_nota", "value": "={{ $json.body?.stop_reason !== 'end_turn' }}", "type": "boolean"}]}, "options": {}},
      "id": "4a400000-0000-4000-8000-000000000013",
      "name": "Devolver folio",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [1980, 0]
    }
  ],
  "connections": {
    "Al ser llamado": {"main": [[{"node": "Pedidos de la semana", "type": "main", "index": 0}]]},
    "Pedidos de la semana": {"main": [[{"node": "Armar factura", "type": "main", "index": 0}]]},
    "Armar factura": {"main": [[{"node": "Facturas emitidas", "type": "main", "index": 0}]]},
    "Facturas emitidas": {"main": [[{"node": "Buscar referencia", "type": "main", "index": 0}]]},
    "Buscar referencia": {"main": [[{"node": "¿Ya existe?", "type": "main", "index": 0}]]},
    "¿Ya existe?": {"main": [[{"node": "Devolver folio", "type": "main", "index": 0}], [{"node": "Emitir factura", "type": "main", "index": 0}]]},
    "Emitir factura": {"main": [[{"node": "¿Emitida?", "type": "main", "index": 0}]]},
    "¿Emitida?": {"main": [[{"node": "Redactar nota con Claude", "type": "main", "index": 0}], [{"node": "¿Reintentable?", "type": "main", "index": 0}]]},
    "¿Reintentable?": {"main": [[{"node": "Esperar Retry-After", "type": "main", "index": 0}], [{"node": "Detener y avisar", "type": "main", "index": 0}]]},
    "Esperar Retry-After": {"main": [[{"node": "Emitir factura", "type": "main", "index": 0}]]},
    "Redactar nota con Claude": {"main": [[{"node": "Devolver folio", "type": "main", "index": 0}]]}
  },
  "active": false,
  "settings": {"executionOrder": "v1", "timezone": "America/Mexico_City", "errorWorkflow": "0000000000000028"},
  "versionId": "5e7f0000-0000-4000-8000-000000000040",
  "pinData": {},
  "meta": {"templateCredsSetupCompleted": true},
  "tags": []
}

Tres detalles del JSON merecen atención. El primero está en Pedidos de la semana: la consulta usa $1, $2 y $3, y los valores llegan aparte por queryReplacement. Pegar el cliente o las fechas dentro del texto del SQL con una expresión funcionaría, pero abriría la puerta a una inyección de SQL si algún día esos valores vinieran de un formulario. El segundo está en Esperar Retry-After: la expresión Math.min(30, Number($json.headers['retry-after'] ?? 2 ** ($runIndex + 1))) lee la cabecera si existe y, si no, usa la espera exponencial con el número de vuelta ($runIndex), siempre con el tope de $30$ segundos. El tercero está en connections: Esperar Retry-After apunta de vuelta a Emitir factura. Es un bucle, y lo que impide que sea infinito es la condición $runIndex < 3 del IF anterior.

El siguiente bloque lee el JSON, lo revisa y lo dibuja. nodos_desde_n8n es la misma función de los días anteriores. revisar_http comprueba lo que se ha visto hoy en cada nodo HTTP Request: que no haya cabeceras de autenticación en los parámetros ni nada con forma de clave, que la autenticación genérica tenga una credencial, que haya un tiempo máximo explícito, que todo POST a un servicio de negocio lleve Idempotency-Key y que el cuerpo de Claude no traiga parámetros de muestreo. Para comprobar que la revisión sirve, el bloque la pasa también por una copia del workflow en la que Emitir factura se configuró importando un cURL: con la clave pegada en una cabecera Authorization, sin la clave de idempotencia y sin tiempo máximo:

python
# Lectura, revisión de seguridad y dibujo del sub-workflow TL Facturación en Python:
wf = json.loads((carpeta / 'flujos' / 'n8n' / 'facturacion_v1.json').read_text(encoding = 'utf-8'))
nodo_claude = json.loads((carpeta / 'flujos' / 'n8n' / 'nodo_http_claude.json').read_text(encoding = 'utf-8'))

def nodos_desde_n8n(wf):
    nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1],
              'x': n['position'][0] / 100, 'y': -n['position'][1] / 100} for n in wf['nodes']]
    aristas = [(o, d['node'], tipo) for o, s in wf['connections'].items()
               for tipo, lista in s.items() for destinos in lista for d in destinos]
    return nodos, aristas

PATRON_SECRETO = re.compile(r'sk-ant-|Bearer\s+\S|"(x-api-key|Authorization)"\s*,\s*"value"\s*:\s*"[^"=]', re.I)
def revisar_http(wf):
    hallazgos = []
    nombres = {n['name'] for n in wf['nodes']}
    for n in wf['nodes']:
        if not n['type'].endswith('.httpRequest'):
            continue
        p, texto = n['parameters'], json.dumps(n, ensure_ascii = False)
        cabeceras = {h['name'].lower() for h in p.get('headerParameters', {}).get('parameters', [])}
        if PATRON_SECRETO.search(texto) or cabeceras & {'authorization', 'x-api-key'}:
            hallazgos.append(f"{n['name']}: cabecera de autenticación en los parámetros (pásela a una credencial)")
        if p.get('authentication') == 'genericCredentialType' and not n.get('credentials'):
            hallazgos.append(f"{n['name']}: autenticación genérica sin credencial")
        if 'timeout' not in p.get('options', {}) and 'pagination' not in p.get('options', {}):
            hallazgos.append(f"{n['name']}: sin timeout explícito (10 000 ms por omisión)")
        if p.get('method') == 'POST' and 'api.anthropic.com' not in p['url'] and 'idempotency-key' not in cabeceras:
            hallazgos.append(f"{n['name']}: POST sin Idempotency-Key")
        if 'api.anthropic.com' in p['url'] and re.search(r'temperature|top_p|top_k', p.get('jsonBody', '')):
            hallazgos.append(f"{n['name']}: parámetros de muestreo en el cuerpo")
    for o, d, _ in nodos_desde_n8n(wf)[1]:
        if o not in nombres or d not in nombres:
            hallazgos.append(f'conexión rota: {o} -> {d}')
    return hallazgos

FORMAS = {'executeWorkflowTrigger': ('disparador', '#1f9d55'), 'postgres': ('accion', '#2a78d6'),
          'code': ('accion', '#2a78d6'), 'httpRequest': ('accion', '#2a78d6'), 'set': ('accion', '#2a78d6'),
          'wait': ('accion', '#2a78d6'), 'if': ('decision', '#eb6834'), 'stopAndError': ('error', '#c0392b'),
          'claude': ('ia', '#7b4bb7')}

def dibujar_flujo(nodos, aristas, titulo):
    fig, ax = plt.subplots(figsize = (14, 5.6))
    pos = {n['id']: (n['x'], n['y']) for n in nodos}
    for n in nodos:
        forma, color = FORMAS['claude' if 'Claude' in n['id'] else n['tipo']]
        x, y = n['x'], n['y']
        if forma == 'decision':
            ax.add_patch(Polygon([(x - 1.0, y), (x, y + 0.75), (x + 1.0, y), (x, y - 0.75)], facecolor = color, edgecolor = 'white'))
        else:
            estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
            ax.add_patch(FancyBboxPatch((x - 0.95, y - 0.4), 1.9, 0.8, boxstyle = estilo,
                                        facecolor = color, edgecolor = 'white', linewidth = 2))
        ax.text(x, y + 0.1, textwrap.fill(n['id'], 14, break_on_hyphens = False), ha = 'center', va = 'center', color = 'white', fontsize = 8)
        ax.text(x, y - 0.27, n['tipo'], ha = 'center', va = 'center', color = 'white', fontsize = 6)
    salidas = {}
    for o, d, tipo in aristas:
        (x1, y1), (x2, y2) = pos[o], pos[d]
        k = salidas.get(o, 0)
        salidas[o] = k + 1
        rad = -0.35 if x2 < x1 else 0.08
        ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12, shrinkA = 22, shrinkB = 24,
                                     color = '#52514e', linewidth = 1.2, connectionstyle = f'arc3,rad={rad}'))
        if wf_tipo.get(o) == 'if':
            ax.text(x1 + (x2 - x1) * 0.4, y1 + (y2 - y1) * 0.4 + 0.15, 'sí' if k == 0 else 'no', fontsize = 8, color = '#eb6834')
    ax.set_xlim(-1.1, max(p[0] for p in pos.values()) + 1.1)
    ax.set_ylim(min(p[1] for p in pos.values()) - 0.9, 0.9)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title(titulo, fontsize = 14)
    return ax

wf_tipo = {n['name']: n['type'].split('.')[-1] for n in wf['nodes']}
nodos, aristas = nodos_desde_n8n(wf)
print('Nodos:', len(nodos), '| conexiones:', len(aristas), '| tipos:', {k: int(v) for k, v in pd.Series(wf_tipo).value_counts().items()})
print('Hallazgos en TL Facturación:', revisar_http(wf) or 'ninguno')
print('Hallazgos en el nodo de Claude:', revisar_http({'nodes': [nodo_claude], 'connections': {}}) or 'ninguno')
pegado = json.loads(json.dumps(wf))
emitir = next(n for n in pegado['nodes'] if n['name'] == 'Emitir factura')
emitir['parameters']['headerParameters']['parameters'] = [{'name': 'Authorization', 'value': 'Bearer EJEMPLO-NO-REAL-0000'}]
del emitir['parameters']['options']['timeout']
print('Hallazgos en una copia importada desde cURL:', revisar_http(pegado))
dibujar_flujo(nodos, aristas, f"{wf['name']}: sub-workflow llamado por el workflow semanal de Brújula")
plt.tight_layout()
plt.show()
Nodos: 13 | conexiones: 14 | tipos: {'httpRequest': 3, 'if': 3, 'code': 2, 'executeWorkflowTrigger': 1, 'postgres': 1, 'wait': 1, 'stopAndError': 1, 'set': 1}
Hallazgos en TL Facturación: ninguno
Hallazgos en el nodo de Claude: ninguno
Hallazgos en una copia importada desde cURL: ['Emitir factura: cabecera de autenticación en los parámetros (pásela a una credencial)', 'Emitir factura: sin timeout explícito (10 000 ms por omisión)', 'Emitir factura: POST sin Idempotency-Key']

Gráfica: El sub-workflow TL Facturación. Lectura, revisión de seguridad y dibujo del sub-workflow TL Facturación

El sub-workflow tiene $13$ nodos y $14$ conexiones, y ningún hallazgo, igual que el nodo de Claude. La copia importada desde cURL delata $3$ problemas en Emitir factura: la cabecera de autenticación en los parámetros, el tiempo máximo implícito de $10$ segundos y el POST sin clave de idempotencia. Ninguno de los tres impide que la petición funcione la primera vez, y esa es justo la razón por la que hay que buscarlos con una revisión: los tres esperan a la semana en que algo sale mal. En el dibujo, el disparador es el óvalo verde, las acciones son rectángulos azules, los tres IF son rombos naranjas, el nodo de Claude es morado y Detener y avisar es el rectángulo rojo del manejador de errores. La flecha curva que regresa desde Esperar Retry-After hasta Emitir factura es el bucle de reintento.

Queda el número que Marisol revisará: el total. La factura de Brújula es la suma de seis renglones iguales, uno por día de lunes a sábado:

$$ F = \sum_{d=1}^{6} q_d \cdot p = 6 \cdot 40 \cdot 3,800 = 912,000 \text{ centavos} = 9,120 \text{ MXN} $$

La lógica detrás de esta fórmula es múltiple:

  • $q_d$ es la cantidad entregada el día $d$ ($40$ baguettes) y $p$ el precio unitario en centavos ($3,800$, los $38$ MXN del catálogo de la clave P02).
  • Se suma en centavos enteros y solo al final se divide entre $100$ para mostrar pesos. Los números con decimales de una computadora no representan exactamente valores como $0.10$, y una suma de importes con decimales puede acabar en $228.60000000000002$ en lugar de $228.60$.
  • Solo entran los pedidos con estado entregado dentro de la semana; un pedido pendiente del lunes siguiente no se factura todavía.
  • En conclusión, el total de la factura sale de los mismos datos que la nota y el correo, y nunca se transcribe a mano: así no puede volver a aparecer un 9 210 donde va un 9 120.

El siguiente bloque crea en memoria una tabla de pedidos con SQLite, ejecuta la misma consulta del nodo Postgres (con ? en lugar de $1, que es la forma de marcar parámetros en SQLite), calcula la factura, muestra por qué se trabaja en centavos y estima el coste mensual de la nota de Claude:

python
# Factura semanal de la Cafetería Brújula en centavos, con la misma consulta del nodo Postgres, en Python:
con = sqlite3.connect(':memory:')
con.execute('CREATE TABLE pedidos (cliente_id TEXT, fecha_entrega TEXT, clave TEXT, cantidad INTEGER, '
            'precio_centavos INTEGER, estado TEXT)')
semana = pd.date_range('2026-03-09', '2026-03-14').strftime('%Y-%m-%d')    # lunes 9 a sábado 14 de marzo
con.executemany('INSERT INTO pedidos VALUES (?, ?, ?, ?, ?, ?)',
                [('brujula', f, 'P02', 40, 3800, 'entregado') for f in semana] +
                [('brujula', '2026-03-16', 'P02', 40, 3800, 'pendiente'), ('mostrador', '2026-03-10', 'P04', 6, 2200, 'entregado')])
consulta = ('SELECT fecha_entrega, clave, cantidad, precio_centavos FROM pedidos WHERE cliente_id = ? '
            "AND fecha_entrega BETWEEN ? AND ? AND estado = 'entregado' ORDER BY fecha_entrega;")
factura = pd.read_sql_query(consulta, con, params = ('brujula', semana[0], semana[-1]))
factura['importe_centavos'] = factura['cantidad'] * factura['precio_centavos']
print(factura.to_string(index = False))
total = int(factura['importe_centavos'].sum())
print(f'Total: {total} centavos = {total // 100:,}.{total % 100:02d} MXN | baguettes: {factura["cantidad"].sum()}')
assert total == 6 * 40 * 3800

# Por qué centavos y no pesos con decimales (un precio hipotético de 38.10 MXN):
suma_flotante = sum([38.10] * 6)
print(f'Seis baguettes a 38.10 con flotantes: {suma_flotante!r} | en centavos: {sum([3810] * 6)} -> {sum([3810] * 6) / 100:.2f}')
# Coste de la nota redactada por Claude (4 facturas al mes, 650 tokens de entrada y 180 de salida):
for modelo in PRECIOS:
    print(f'{modelo}: {usd(modelo, 650, 180):.6f} USD por nota | {4 * usd(modelo, 650, 180):.4f} USD al mes')
fecha_entrega clave  cantidad  precio_centavos  importe_centavos
   2026-03-09   P02        40             3800            152000
   2026-03-10   P02        40             3800            152000
   2026-03-11   P02        40             3800            152000
   2026-03-12   P02        40             3800            152000
   2026-03-13   P02        40             3800            152000
   2026-03-14   P02        40             3800            152000
Total: 912000 centavos = 9,120.00 MXN | baguettes: 240
Seis baguettes a 38.10 con flotantes: 228.60000000000002 | en centavos: 22860 -> 228.60
claude-haiku-5-5: 0.000155 USD por nota | 0.0006 USD al mes
claude-opus-5-5: 0.006200 USD por nota | 0.0248 USD al mes

La consulta devuelve exactamente los $6$ renglones de la semana del 9 al 14 de marzo, con $40$ baguettes cada uno y un importe de $152,000$ centavos; el pedido pendiente del lunes 16 y la venta de mostrador quedan fuera. El total es $912,000$ centavos, $9,120.00$ MXN por $240$ baguettes, y la comprobación assert lo confirma contra la fórmula. La línea siguiente muestra el riesgo que se evita: seis baguettes a un precio hipotético de $38.10$ sumadas con decimales dan $228.60000000000002$; en centavos, $22,860$, que se muestran como $228.60$ sin redondeo. La Tabla 3 es la factura tal como la verá Marisol.

Tabla 3. Factura semanal de la Cafetería Brújula, semana del 9 al 14 de marzo de 2026 (sin impuestos; el tratamiento fiscal lo confirma el contador de Trigo Lento).

Fecha de entregaClaveProductoCantidadPrecio unitario (MXN)Importe (MXN)
Lunes 9P02Baguette tradicional4038.001 520.00
Martes 10P02Baguette tradicional4038.001 520.00
Miércoles 11P02Baguette tradicional4038.001 520.00
Jueves 12P02Baguette tradicional4038.001 520.00
Viernes 13P02Baguette tradicional4038.001 520.00
Sábado 14P02Baguette tradicional4038.001 520.00
Total240 piezas9 120.00

El coste de la nota es la última línea del bloque: $0.0062$ USD por nota con Opus 5.5 y $0.0248$ USD al mes con cuatro facturas, el $0.3%$ del rubro de $8$ USD de Claude. La facturación de Brújula no cambia el presupuesto; cambia el sábado de Valeria.

Errores e idempotencia en la emisión


El sub-workflow ya sabe qué hacer con cada código, pero falta comprobar que la política de la Tabla 2 se comporta como se espera cuando los errores llegan de verdad. El siguiente bloque genera $500$ primeras respuestas con una distribución inventada pero verosímil para un servicio pequeño ($85%$ correctas, $5%$ de 429, $5%$ de errores del servidor y $5%$ de errores del cliente), aplica la política y simula los reintentos: cada reintento sale bien con probabilidad $0.7$, con un máximo de cuatro intentos, y las esperas siguen la fórmula de la sección de límites de tasa. Las cifras son de juguete; lo que se comprueba es la lógica:

python
# Política por código de estado aplicada a 500 respuestas simuladas, con reintentos, en Python:
CODIGOS = [200, 201, 400, 401, 403, 404, 422, 429, 500, 502, 503, 529]
PROBS = [0.80, 0.05, 0.01, 0.005, 0.005, 0.01, 0.02, 0.05, 0.015, 0.01, 0.015, 0.01]
def politica(codigo):
    if codigo in (200, 201):
        return 'seguir'
    if codigo == 429:
        return 'esperar Retry-After'
    if codigo in (500, 502, 503, 529):
        return 'reintentar con espera exponencial'
    return 'detener y avisar'

MAX_INTENTOS, P_EXITO_REINTENTO = 4, 0.7
primeros = rng.choice(CODIGOS, size = 500, p = PROBS)
filas = []
for codigo in primeros:
    accion = politica(codigo)
    intentos, espera, final = 1, 0.0, accion
    if accion in ('esperar Retry-After', 'reintentar con espera exponencial'):
        final = 'agotó los reintentos'
        for k in range(1, MAX_INTENTOS):
            espera += rng.integers(1, 6) if codigo == 429 else min(30, 2 ** k) + rng.uniform(0, 1)
            intentos += 1
            if rng.random() < P_EXITO_REINTENTO:
                final = 'éxito tras reintento'
                break
    elif accion == 'seguir':
        final = 'éxito a la primera'
    filas.append({'código': codigo, 'acción': accion, 'final': final, 'intentos': intentos, 'espera_s': espera})
politicas = pd.DataFrame(filas)
resumen_codigos = politicas.groupby(['código', 'acción']).agg(respuestas = ('final', 'size'),
                                                              intentos = ('intentos', 'sum')).reset_index()
print(resumen_codigos.to_string(index = False))
print(politicas['final'].value_counts().to_string())
reintentadas = politicas[politicas['intentos'] > 1]
print(f'Peticiones totales: {politicas["intentos"].sum()} | espera media de las reintentadas: '
      f'{reintentadas["espera_s"].mean():.1f} s | máxima: {reintentadas["espera_s"].max():.1f} s')

# Respuestas por código y acción tomada (semáforo):
colores = {'seguir': '#1f9d55', 'detener y avisar': '#e0a800', 'esperar Retry-After': '#c0392b',
           'reintentar con espera exponencial': '#c0392b'}
conteo = politicas['código'].value_counts().reindex(CODIGOS, fill_value = 0)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (11, 4.2), gridspec_kw = {'width_ratios': [2, 1]})
ax1.bar([str(c) for c in CODIGOS], conteo.values, color = [colores[politica(c)] for c in CODIGOS])
for i, v in enumerate(conteo.values):
    ax1.text(i, v * 1.15, str(v), ha = 'center', fontsize = 8)
ax1.set_yscale('log')
ax1.set_ylim(0.8, 900)
ax1.set_xlabel('Código de estado de la primera respuesta')
ax1.set_ylabel('Respuestas (escala logarítmica)')
ax1.set_title('500 respuestas simuladas', fontsize = 13)
finales = politicas['final'].value_counts()
color_final = {'éxito a la primera': '#1f9d55', 'éxito tras reintento': '#2a78d6', 'detener y avisar': '#e0a800',
               'agotó los reintentos': '#c0392b'}
ax2.barh(finales.index, finales.values, color = [color_final[f] for f in finales.index])
for i, v in enumerate(finales.values):
    ax2.text(v + 5, i, str(v), va = 'center', fontsize = 9)
ax2.set_xlim(0, finales.max() * 1.25)
ax2.set_title('Resultado final', fontsize = 13)
fig.suptitle('Política por código de estado: verde seguir, ámbar detener, rojo reintentar', fontsize = 15)
plt.tight_layout()
plt.show()
 código                            acción  respuestas  intentos
    200                            seguir         400       400
    201                            seguir          25        25
    400                  detener y avisar           3         3
    401                  detener y avisar           4         4
    403                  detener y avisar           3         3
    404                  detener y avisar           5         5
    422                  detener y avisar          12        12
    429               esperar Retry-After          29        71
    500 reintentar con espera exponencial           9        19
    502 reintentar con espera exponencial           1         2
    503 reintentar con espera exponencial           8        21
    529 reintentar con espera exponencial           1         2
final
éxito a la primera      425
éxito tras reintento     46
detener y avisar         27
agotó los reintentos      2
Peticiones totales: 567 | espera media de las reintentadas: 4.3 s | máxima: 15.5 s

Gráfica: Errores e idempotencia en la emisión. Política por código de estado aplicada a 500 respuestas simuladas, con reintentos

La Tabla 4 resume la salida del bloque por grupo de códigos.

Tabla 4. Resultado de la política por código de estado sobre 500 respuestas simuladas (distribución ilustrativa).

GrupoPrimeras respuestasAcciónResultado
200 y 201425Seguir425 éxitos a la primera
400, 401, 403, 404 y 42227Detener y avisar27 avisos a Valeria, ningún reintento
42929Esperar Retry-AfterReintentadas con 71 peticiones en total
500, 502, 503 y 52919Espera exponencialReintentadas con 44 peticiones en total
Total50046 éxitos tras reintento y 2 agotadas

Las cifras se leen en tres partes:

  1. En primer lugar, de las $48$ respuestas reintentables, $46$ terminaron bien tras uno o más reintentos y solo $2$ agotaron los cuatro intentos, frente a las $1.3$ que predice $(1 - q)^{K-1} = 0.027$ aplicado a $48$. Las dos pasan a Detener y avisar, con su código y su referencia, y una persona decide.
  2. En segundo lugar, los $27$ errores del cliente no se reintentaron nunca. Repetir un 422 habría dado otro 422, y repetir un 401 con una clave revocada, otro 401. Cada uno es un aviso con información para corregir, no una petición más.
  3. En tercer lugar, la política cuesta poco: $567$ peticiones para $500$ operaciones, con una espera media de $4.3$ segundos en las reintentadas y una máxima de $15.5$. En un proceso que corre una vez por semana, eso es nada.

Queda la segunda objeción de Don Ernesto, la de las dos facturas. Hay un caso que la Tabla 2 no resuelve: FacturaFácil recibe el POST, emite la factura y, cuando va a contestar, la respuesta se pierde por el camino (un tiempo agotado, un 504 de un proxy intermedio, una caída de red). Para el sub-workflow, la petición falló, y la política dice que se reintente. Pero la factura ya existe. Como POST no es idempotente, el reintento crea otra. Marisol recibe dos facturas por la misma semana, su contador las registra y alguien tiene que cancelar una ante la autoridad fiscal.

La defensa estándar es la cabecera Idempotency-Key, que muchas API de pagos y facturación aceptan y que el grupo de trabajo de API HTTP del IETF propuso normalizar en un borrador (caducado en su versión 07, revisado en octubre de 2026, aunque la práctica sigue extendida): el cliente manda con cada POST una clave única para esa operación, y el servidor, si ya procesó una petición con la misma clave, no crea nada nuevo y devuelve el resultado de la primera. Para Trigo Lento, la clave natural es la referencia: brujula-2026-03-09 identifica la factura de esa semana, y solo de esa. La consulta previa de Facturas emitidas es una segunda defensa, por si el servicio no guardara las claves el tiempo suficiente.

¿Cuántas facturas dobles habría sin ninguna defensa? Si cada respuesta se pierde con probabilidad $p$ y el sub-workflow reintenta hasta recibir una, el número de facturas duplicadas en $N$ semanas es, en promedio:

$$ E[D] = N \cdot \frac{p}{1 - p} $$

La lógica detrás de esta fórmula es múltiple:

  • Cada semana, el número de respuestas perdidas antes de la primera que llega sigue una distribución geométrica con media $p / (1 - p)$, y cada respuesta perdida es una factura emitida de más.
  • Con $p = 0.06$ (seis de cada cien respuestas perdidas, un supuesto pesimista pero posible en una red casera) y $N = 52$ semanas, $E[D] = 52 \cdot 0.06 / 0.94 = 3.32$ facturas dobles al año.
  • Con la clave de idempotencia, el número de peticiones sigue siendo el mismo, pero cada reintento recibe la factura original: $D = 0$ sea cual sea $p$.
  • En conclusión, el reintento es bueno para la disponibilidad y malo para la exactitud, salvo que la operación sea idempotente; la cabecera convierte un POST en una operación que se puede repetir sin miedo.

El siguiente bloque simula un año de sábados con las tres estrategias (sin clave, con Idempotency-Key y con consulta previa) y, para ver la variabilidad, $300$ años sin clave:

python
# Idempotencia al emitir: 52 sábados con respuestas perdidas, sin clave, con Idempotency-Key y con consulta previa, en Python:
def simular_post(url, cuerpo, rng, emitidas, cabeceras = None, p_perdida = 0.06):
    clave = (cabeceras or {}).get('Idempotency-Key')
    previa = next((f for f in emitidas if clave and f['clave'] == clave), None)
    if previa is None:                                    # sin clave conocida, la API emite una factura nueva
        previa = {'folio': f'FF-{len(emitidas) + 1:05d}', 'referencia': cuerpo['referencia'], 'clave': clave}
        emitidas.append(previa)
    if rng.random() < p_perdida:                          # la factura se emitió, pero la respuesta se perdió
        return Respuesta(504, {'error': 'gateway_timeout'})
    return Respuesta(201, {'folio': previa['folio'], 'referencia': cuerpo['referencia']})

def un_anio(estrategia, rng, p_perdida = 0.06):
    emitidas, peticiones = [], 0
    for semana_n in range(1, 53):
        referencia = f'brujula-2026-S{semana_n:02d}'
        while True:
            if estrategia == 'consulta previa' and any(f['referencia'] == referencia for f in emitidas):
                break                                     # el GET de Facturas emitidas ya la encuentra
            cabeceras = {'Idempotency-Key': referencia} if estrategia == 'Idempotency-Key' else None
            peticiones += 1
            r = simular_post(f'{BASE}/facturas', {'referencia': referencia}, rng, emitidas, cabeceras, p_perdida)
            if r.status_code == 201:
                break
    return {'estrategia': estrategia, 'peticiones': peticiones, 'facturas_emitidas': len(emitidas),
            'duplicadas': len(emitidas) - 52}

anios = pd.DataFrame([un_anio(e, np.random.default_rng(2026 + i)) for i, e in
                      enumerate(['sin clave', 'Idempotency-Key', 'consulta previa'])])
print(anios.to_string(index = False))
p = 0.06
print(f'Duplicadas esperadas sin clave en 52 semanas: 52 * p / (1 - p) = {52 * p / (1 - p):.2f}')
sin_clave = np.array([un_anio('sin clave', np.random.default_rng(s))['duplicadas'] for s in range(300)])
print(f'300 años simulados sin clave: media {sin_clave.mean():.2f} duplicadas, años sin ninguna: {np.mean(sin_clave == 0):.1%}')

# Duplicadas por año sin clave frente a las dos defensas:
fig, ax = plt.subplots(figsize = (8, 4))
valores, cuentas = np.unique(sin_clave, return_counts = True)
ax.bar(valores, cuentas / len(sin_clave), color = '#c0392b', label = 'Sin clave (300 años simulados)')
ax.axvline(sin_clave.mean(), color = '#52514e', linestyle = '--', label = f'Media: {sin_clave.mean():.2f}')
ax.bar([0], [1.0], width = 0.25, color = '#1f9d55', alpha = 0.6, label = 'Con Idempotency-Key: siempre 0')
ax.set_xlabel('Facturas duplicadas a Brújula en un año')
ax.set_ylabel('Proporción de años')
ax.set_title('Una respuesta perdida no debe volverse una factura doble', fontsize = 14)
ax.legend(fontsize = 9)
plt.tight_layout()
plt.show()
     estrategia  peticiones  facturas_emitidas  duplicadas
      sin clave          53                 53           1
Idempotency-Key          57                 52           0
consulta previa          52                 52           0
Duplicadas esperadas sin clave en 52 semanas: 52 * p / (1 - p) = 3.32
300 años simulados sin clave: media 3.52 duplicadas, años sin ninguna: 4.0%

Gráfica: Errores e idempotencia en la emisión. Idempotencia al emitir: 52 sábados con respuestas perdidas, sin clave, con Idempotency-Key y con consulta previa

En el año simulado con la semilla del bloque, sin clave se emitieron $53$ facturas para $52$ semanas: $1$ duplicada. Con Idempotency-Key hubo $57$ peticiones, cinco reintentos por respuestas perdidas, y $52$ facturas exactas; con la consulta previa, $52$ y $52$. Un año con una sola duplicada podría parecer tranquilizador, y por eso el bloque repite el experimento: en $300$ años sin clave, la media es de $3.52$ facturas dobles al año, cerca de las $3.32$ de la fórmula, y solo el $4.0%$ de los años termina sin ninguna. La gráfica muestra la distribución: sin clave, lo normal son entre dos y cinco facturas dobles al año; con la clave, siempre cero. La respuesta a Don Ernesto es, entonces, un número y una cabecera: sin la clave, unas tres cancelaciones al año; con la clave, ninguna.

Consideraciones finales


El día empezó con dos objeciones de Don Ernesto y termina con dos respuestas. La primera era si hacía falta un programador para integrar un servicio sin conector, y la respuesta es que hace falta leer: la documentación de FacturaFácil, traducida a las cinco partes de la carta, cabe en los campos del nodo HTTP Request. La segunda era quién cancelaría la factura doble, y la respuesta es que nadie, porque una clave de idempotencia la impide. Entre las dos hubo cifras que conviene recordar: pedir páginas de $100$ en lugar de $10$ hace la descarga $18.1$ veces más rápida; ignorar Retry-After no ahorra tiempo y gasta $49$ peticiones de más; y, sin clave, un año de reintentos dejaría unas $3.3$ facturas dobles. El nodo HTTP Request no es un atajo para quien no tiene conector: es la forma de hablar con cualquier servicio en su propio idioma, y obliga a decidir lo que un conector decide en silencio.

Antes de poner en producción una integración sin conector, conviene hacerse algunas preguntas:

  • ¿Dónde está la clave? Si la respuesta es “en una cabecera del nodo”, muévala a una credencial y revoque la que quedó en el workflow.
  • ¿Qué hace su workflow con un 429, con un 503 y con un 422? Si los tres reciben el mismo trato, al menos uno está mal tratado.
  • ¿Qué pasa si se pierde la respuesta de un POST? Si el reintento puede crear un segundo pedido, una segunda factura o un segundo cobro, la operación necesita una clave de idempotencia o una consulta previa.
  • ¿Qué tamaño de página está pidiendo, y cuál es el máximo que admite la API?

Las cifras de hoy tienen límites claros. La API de FacturaFácil es ficticia y su cubeta de fichas es un modelo; las API reales anuncian sus límites de formas distintas (por minuto, por día, por clave, por cuenta) y algunas no mandan Retry-After. La distribución de códigos de estado y la probabilidad de perder una respuesta son supuestos de juguete, elegidos para que los fenómenos se vean en un año simulado. Los JSON se verificaron contra la documentación y el código fuente publicado de n8n, no contra una exportación de la instancia. Y el sub-workflow no calcula impuestos: el tratamiento fiscal de los alimentos lo confirma el contador de Trigo Lento, y la factura solo contiene los datos fiscales de Brújula que exige la ley, se envía solo a la dirección que Marisol registró y se conserva el tiempo que marca la norma, no más.

Un gran libro que habla de esto es Designing Data-Intensive Applications, en el que Martin Kleppmann explica por qué las redes pierden mensajes, por qué un reintento puede duplicar una operación y cómo la idempotencia y los identificadores únicos de cada operación permiten construir sistemas que se equivocan sin causar daño. Leerlo después de esta entrada le ayudará a reconocer la factura doble de Marisol en cualquier sistema que reintente: pagos, reservaciones, inventarios o mensajes.

Conclusiones clave


REST

  • Toda API REST es una conversación HTTP con cinco partes (método y URL, parámetros, autenticación, cuerpo y código de estado), y cada una tiene su campo en el nodo HTTP Request y su nombre en el JSON exportado.
  • Los secretos van en credenciales (Header Auth, Bearer, Basic, OAuth2), nunca en headerParameters ni en jsonBody; después de importar desde cURL se borran las cabeceras de autenticación, y la revisión del Bloque 8 lo comprueba.
  • Claude se llama por HTTP con x-api-key desde una credencial, anthropic-version: 2023-06-01 y el mismo cuerpo del SDK, sin parámetros de muestreo, con el esfuerzo en low y el stop_reason comprobado antes de leer el texto.

Paginación y límites

  • La paginación por cursor descargó los $1,000$ registros sin repetir ninguno; el nodo la configura con updateAParameterInEachRequest, $response.body.next_cursor y una condición de fin.
  • $T(b) = \max(\lceil n/b \rceil(\ell + bt), (\lceil n/b \rceil - C)/\rho)$: con páginas de $10$ la descarga tardó $90.5$ segundos y con páginas de $100$, $5.0$.
  • Respetar Retry-After terminó en $40.4$ segundos con $68$ peticiones; el cliente impaciente, en $41.6$ con $117$.
  • La política por código siguió en $425$ de $500$ casos, avisó en $27$, recuperó $46$ con reintentos y agotó $2$, frente a una probabilidad teórica de $0.027$ por error pasajero.

Facturación

  • TL Facturación es un sub-workflow de $13$ nodos llamado por el workflow semanal: consulta parametrizada, factura en centavos, consulta previa paginada, emisión con Idempotency-Key, bucle de espera con tope y salida de error.
  • La factura de Brújula es $6 \cdot 40 \cdot 3,800 = 912,000$ centavos, $9,120$ MXN; la nota de Claude cuesta $0.0248$ USD al mes y la revisa Valeria antes de enviarla.
  • Sin clave de idempotencia, perder el $6%$ de las respuestas deja $3.32$ facturas dobles al año según la fórmula y $3.52$ en la simulación; con la clave, ninguna.

Mañana, en el Día 41: Datos masivos: loops, lotes y sub-workflows. Valeria quiere importar dos años de pedidos de la hoja de cálculo, $11,232$ filas, y saber qué comentan los clientes; descubrirá que el tamaño de lote que hoy decidía el tiempo de una descarga también decide la memoria y el coste, y clasificará miles de comentarios con la API de lotes de Claude a la mitad del precio.

Referencias


  1. Fielding, R. T. (2000). Architectural Styles and the Design of Network-based Software Architectures (tesis doctoral). University of California, Irvine. https://ics.uci.edu/~fielding/pubs/dissertation/top.htm
  2. Fielding, R., Nottingham, M. y Reschke, J. (Eds.) (2022). HTTP Semantics (RFC 9110). IETF. https://www.rfc-editor.org/rfc/rfc9110
  3. Nottingham, M. y Fielding, R. (2012). Additional HTTP Status Codes (RFC 6585). IETF. https://www.rfc-editor.org/rfc/rfc6585
  4. Nottingham, M. (2017). Web Linking (RFC 8288). IETF. https://www.rfc-editor.org/rfc/rfc8288
  5. Jena, J. y Dalal, S. (2025). The Idempotency-Key HTTP Header Field (Internet-Draft draft-ietf-httpapi-idempotency-key-header-07, caducado). IETF. https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
  6. n8n (2026). Documentación del nodo HTTP Request, paginación y manejo de límites de tasa. https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/
  7. n8n (2026). Documentación de Execute Sub-workflow Trigger y de las credenciales genéricas. https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executeworkflowtrigger/
  8. Anthropic (2026). Messages API, errores y respaldo del lado del servidor. Documentación de Claude. https://platform.claude.com/docs
  9. Kleppmann, M. (2017). Designing Data-Intensive Applications. O’Reilly Media.
comments powered by Disqus