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$:
# 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.
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/facturaspide la lista de facturas;POSTa la misma dirección pide crear una nueva. Los métodos que usará la serie sonGET(leer),POST(crear),PUTyPATCH(reemplazar o modificar) yDELETE(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,PUTyDELETE, pero noPOST). 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-KeyoAuthorizationdice 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,PUTyPATCH, el cuerpo lleva los datos, casi siempre en JSON, con la cabeceraContent-Type: application/jsonque 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
2xxdicen que salió bien, los4xxque el error es del cliente (una petición mal hecha, una clave equivocada, un recurso que no existe) y los5xxque el error es del servidor. El código429, “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ón | Ejemplo en FacturaFácil | Campo del nodo | Nombre en el JSON exportado |
|---|---|---|---|
| Método | POST | Method | method (por omisión GET) |
| URL | https://api.facturafacil.example/v1/facturas | URL | url |
| Parámetros de consulta | cliente=brujula, limit=100 | Send Query Parameters | sendQuery, queryParameters |
| Cabeceras | Idempotency-Key: brujula-2026-03-09 | Send Headers | sendHeaders, headerParameters |
| Autenticación | X-API-Key desde una credencial | Authentication | authentication, genericAuthType, credentials |
| Cuerpo | JSON con cliente, renglones y total | Send Body, Body Content Type | sendBody, specifyBody, jsonBody |
| Código de estado | 201, 422, 429, 503 | Options, Response | options.response.response.fullResponse y neverError |
| Tiempo máximo de espera | $30$ segundos | Options, Timeout | options.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 laN8N_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, comoX-API-Key: <clave>. Es el caso de FacturaFácil y de la API de Claude, que espera la clave en la cabecerax-api-key. - Bearer Auth (
httpBearerAuth): la cabeceraAuthorization: Bearer <token>, muy común en las API modernas. - Basic Auth (
httpBasicAuth): usuario y contraseña codificados en la cabeceraAuthorization. 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
Linkconrel="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:
# 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:
# 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.
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:
# 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

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ódigos | Significado | Acción | Por qué |
|---|---|---|---|
200, 201 | Correcto, recurso creado | Seguir | La petición hizo lo que se pidió |
400, 422 | Petición mal formada o datos inválidos | Detener y avisar | Repetirla dará el mismo error; hay que corregir los datos |
401, 403 | Sin credencial válida o sin permiso | Detener y avisar | La clave caducó, se revocó o no tiene el permiso; lo arregla una persona |
404 | El recurso no existe | Detener y avisar | URL equivocada o recurso borrado |
429 | Demasiadas peticiones | Esperar Retry-After y repetir | La petición era buena; llegó antes de tiempo |
500, 502, 503, 529 | Error o sobrecarga del servidor | Reintentar con espera exponencial | Suele 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.
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:
- En primer lugar, la paginación tiene su propio intervalo (
requestInterval, en milisegundos) y un tope de páginas (limitPagesFetchedymaxRequests). 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 siemprehas_more: true. - En segundo lugar, el procesamiento por lotes (
batching, conbatchSizeybatchInterval) 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. - 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 leeRetry-Afterni distingue un503de un422: 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 conneverError, 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:
{
"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:
# 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:
- Al ser llamado (
executeWorkflowTrigger): declara tres entradas,cliente_id,semana_inicioysemana_fin. El modo Define using fields below (inputSource: workflowInputs) hace que el workflow que llama vea los tres campos y no pueda olvidar ninguno. - Pedidos de la semana (
postgres): consulta los pedidos entregados de la semana con una consulta parametrizada,$1,$2y$3, sin pegar valores dentro del SQL. - Armar factura (
code): suma los renglones en centavos y construye la referenciabrujula-2026-03-09, cliente más lunes de la semana. - Facturas emitidas (
httpRequest,GET): recorre por cursor las facturas del cliente, conlimit=100, un intervalo de $1,000$ ms y un tope de $20$ páginas. - Buscar referencia (
code): busca en todas las páginas una factura con la misma referencia. - ¿Ya existe? (IF): si existe, el sub-workflow devuelve su folio sin emitir nada.
- Emitir factura (
httpRequest,POST): crea la factura con la cabeceraIdempotency-Keyigual a la referencia. - ¿Emitida? (IF):
200o201siguen adelante. - ¿Reintentable? (IF):
429,500,502,503y529, con menos de tres vueltas, van a esperar. - 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. - 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 ensettings.errorWorkflow, avisa a Valeria. - Redactar nota con Claude (
httpRequest): el nodo de la sección anterior. - Devolver folio (Edit Fields): entrega al workflow que llamó el folio, el total en centavos, la nota y la marca
revisar_notasi elstop_reasonno fueend_turn.
Este es el JSON del sub-workflow, con los identificadores sustituidos por valores de ejemplo como manda la regla A.4.3:
{
"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:
# 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']

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
entregadodentro 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:
# 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 entrega | Clave | Producto | Cantidad | Precio unitario (MXN) | Importe (MXN) |
|---|---|---|---|---|---|
| Lunes 9 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Martes 10 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Miércoles 11 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Jueves 12 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Viernes 13 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Sábado 14 | P02 | Baguette tradicional | 40 | 38.00 | 1 520.00 |
| Total | 240 piezas | 9 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:
# 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

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).
| Grupo | Primeras respuestas | Acción | Resultado |
|---|---|---|---|
200 y 201 | 425 | Seguir | 425 éxitos a la primera |
400, 401, 403, 404 y 422 | 27 | Detener y avisar | 27 avisos a Valeria, ningún reintento |
429 | 29 | Esperar Retry-After | Reintentadas con 71 peticiones en total |
500, 502, 503 y 529 | 19 | Espera exponencial | Reintentadas con 44 peticiones en total |
| Total | 500 | 46 éxitos tras reintento y 2 agotadas |
Las cifras se leen en tres partes:
- 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.
- En segundo lugar, los $27$ errores del cliente no se reintentaron nunca. Repetir un
422habría dado otro422, y repetir un401con una clave revocada, otro401. Cada uno es un aviso con información para corregir, no una petición más. - 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
POSTen 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:
# 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%

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 un503y con un422? 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
headerParametersni enjsonBody; 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-keydesde una credencial,anthropic-version: 2023-06-01y el mismo cuerpo del SDK, sin parámetros de muestreo, con el esfuerzo enlowy elstop_reasoncomprobado 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_cursory 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-Afterterminó 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ónes un sub-workflow de $13$ nodos llamado por el workflow semanal: consulta parametrizada, factura en centavos, consulta previa paginada, emisión conIdempotency-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
- 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
- Fielding, R., Nottingham, M. y Reschke, J. (Eds.) (2022). HTTP Semantics (RFC 9110). IETF. https://www.rfc-editor.org/rfc/rfc9110
- Nottingham, M. y Fielding, R. (2012). Additional HTTP Status Codes (RFC 6585). IETF. https://www.rfc-editor.org/rfc/rfc6585
- Nottingham, M. (2017). Web Linking (RFC 8288). IETF. https://www.rfc-editor.org/rfc/rfc8288
- 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/
- 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/
- 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/
- Anthropic (2026). Messages API, errores y respaldo del lado del servidor. Documentación de Claude. https://platform.claude.com/docs
- Kleppmann, M. (2017). Designing Data-Intensive Applications. O’Reilly Media.

