Durante todo el módulo, Node ha sido el servidor: alguien pedía y nosotros respondíamos. Ahora le damos la vuelta al papel. Un backend real casi nunca vive solo: cobra a través de una pasarela de pago, envía correos con un proveedor, consulta tipos de cambio, valida direcciones. En todos esos casos tu servidor es el cliente de otro servidor.
Y ser cliente tiene sus propios peligros, distintos de los que ya conoces. El principal es que dependes de una máquina que no controlas: puede tardar treinta segundos, puede devolver un 500, puede estar caída justo cuando tu página tiene más visitas. Si tu código no lo previene, la lentitud de un tercero se convierte en la lentitud de tu plataforma.
Escena Viva quiere mostrar los precios también en libras para el público británico. Al terminar tendrás src/servicios/cambio-divisas.js: una llamada a una API pública con tiempo límite, reintentos, caché en memoria con caducidad y degradación elegante —si el proveedor falla, se muestran solo euros en vez de romper la página.
Contenido
- Node como cliente:
fetch,http.requesty los clientes de terceros - La petición
GETconfetchy el error clásico - Leer la respuesta:
json(),text()y las cabeceras POSTcon cabeceras y cuerpo JSON- Tiempos límite y cancelación con
AbortController - Reintentos: qué es seguro repetir y qué no
- Qué hay debajo:
http.request src/servicios/cambio-divisas.js: caché y degradación elegante- Claves de API y reúso de conexiones
- Node como cliente:
fetch, http.request y los clientes de terceros
fetch, http.request y los clientes de tercerosNode tiene tres formas de hacer una petición HTTP saliente, y elegir bien es sencillo si entiendes de dónde viene cada una.
fetch (global) |
http.request |
Terceros (axios, undici, got) | |
|---|---|---|---|
| Instalación | Ninguna, es global desde Node 18 | Ninguna, módulo del núcleo | npm install (Módulo 5) |
| API | Promesas, igual que en el navegador | Callbacks y streams | Promesas, con azúcar |
| Cuerpo JSON | await respuesta.json() |
Acumular trozos a mano | Automático |
| Errores HTTP | No rechaza: hay que mirar ok |
No rechaza | axios sí rechaza en 4xx/5xx |
| Reintentos, agentes | A mano | A mano | Incluidos o por opción |
| Cuándo usarlo | Por defecto | Entender el fondo, control fino de streams | Proyectos con muchas integraciones |
La recomendación del curso es directa: fetch por defecto. Es estándar, no añade dependencias, y el código que escribas funciona igual en el navegador y en Node. undici merece una mención especial porque es la implementación que hay debajo de fetch en Node; usarlo directamente da control sobre los agentes y las conexiones, y lo veremos por encima en el apartado 9.
- La petición
GET con fetch y el error clásico
GET con fetch y el error clásicoUna llamada básica son dos líneas —const respuesta = await fetch(url) y const datos = await respuesta.json()—, pero este es el error que comete todo el mundo la primera vez:
// MAL: parece correcto y no lo es.
try {
const datos = await (await fetch('https://api.ejemplo.test/tipos?base=EUR')).json();
return datos.tipos.GBP;
} catch (error) {
console.error('Fallo la llamada:', error);
}fetch no rechaza la promesa cuando el servidor responde con 404 o 500. Desde su punto de vista, una respuesta 500 es un éxito: la petición viajó, el servidor contestó, tienes tu respuesta. Que el contenido sea un error es asunto tuyo.
Solo rechaza cuando no ha habido respuesta: fallo de red, DNS que no resuelve, conexión rechazada, certificado TLS inválido, o cancelación explícita.
| Situación | ¿fetch rechaza? |
Qué obtienes |
|---|---|---|
200 OK |
No | response.ok === true |
404 Not Found |
No | response.ok === false, status 404 |
500 Internal Server Error |
No | response.ok === false, status 500 |
| El servidor no responde / no existe | Sí | TypeError: fetch failed, con cause |
| Tiempo límite agotado / cancelado | Sí | AbortError o TimeoutError |
En el código malo de arriba, un 500 que devuelve HTML hace que respuesta.json() lance un SyntaxError, y acabas depurando un error de JSON cuando el problema real era otro. La comprobación es obligatoria:
const respuesta = await fetch(url);
if (!respuesta.ok) {
const error = new Error(`La API respondio ${respuesta.status} ${respuesta.statusText}`);
error.codigo = 'SERVICIO_EXTERNO_CAIDO';
error.estadoExterno = respuesta.status; // hara falta para decidir si reintentar
throw error;
}Traducir el fallo ajeno a nuestro vocabulario de dominio es lo que permite que el resto del sistema lo trate igual que cualquier otro error: SERVICIO_EXTERNO_CAIDO ya está en la tabla de la lección 04-02 y vale 503.
- Leer la respuesta:
json(), text() y las cabeceras
json(), text() y las cabecerasEl objeto Response tiene el estado, las cabeceras y el cuerpo, y el cuerpo se lee una sola vez: es un stream, y consumirlo lo agota.
respuesta.status; // 200
respuesta.ok; // true si status esta entre 200 y 299
respuesta.headers.get('content-type'); // 'application/json' (insensible a mayusculas)
await respuesta.json(); // parsea el cuerpo como JSON
await respuesta.text(); // el cuerpo como texto
await respuesta.arrayBuffer(); // binario: un cartel, un PDFIntentar leerlo dos veces lanza TypeError: Body is unusable. Si necesitas el texto y el JSON —muy útil para dar un mensaje de error decente—, lee el texto una vez y parsea tú:
const texto = await respuesta.text();
if (!respuesta.ok) {
// El cuerpo de un error suele traer una pista; se registra recortado.
console.error(`[divisas] ${respuesta.status}: ${texto.slice(0, 200)}`);
throw errorDominio('SERVICIO_EXTERNO_CAIDO', `La API respondio ${respuesta.status}`);
}
const datos = JSON.parse(texto);respuesta.headers es un objeto Headers, no un diccionario: se consulta con .get() y es insensible a mayúsculas. Dos cabeceras que conviene mirar en las API de terceros son retry-after (segundos que pide esperar tras un 429) y las de límite de tasa, que suelen llamarse x-ratelimit-remaining.
POST con cabeceras y cuerpo JSON
POST con cabeceras y cuerpo JSONEnviar datos es el segundo argumento de fetch:
const respuesta = await fetch('https://api.pagos.test/cobros', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.CLAVE_PAGOS}`, // nunca en el codigo
Accept: 'application/json'
},
// El cuerpo se envia como CADENA: hay que serializar a mano.
body: JSON.stringify({ pedidoId: 'ped-000042', importeCentimos: 5000, moneda: 'EUR' })
});Los dos olvidos habituales van juntos: fetch no serializa por ti (pasarle un objeto en body acaba enviando la cadena [object Object]) y no pone el Content-Type de JSON por su cuenta, así que el servidor recibe algo que no sabe interpretar y responde 400. Si el cuerpo es un formulario, body: new URLSearchParams({...}) sí pone el tipo correcto automáticamente.
- Tiempos límite y cancelación con
AbortController
AbortControllerEste apartado es el más importante de la lección. fetch no tiene tiempo límite por defecto. Si la API del otro lado acepta la conexión y luego se queda pensando, tu await espera. Y espera. Con el tiempo por defecto del sistema, podrían ser minutos.
Ahora suma el efecto: cada petición de un usuario a tu servidor dispara una llamada externa que tarda dos minutos. Las peticiones se acumulan, los sockets se agotan y tu plataforma cae por culpa de un tercero. Esto no es un caso teórico: es la forma más común en que un backend sano se degrada.
La solución es un AbortSignal. Desde Node 17.3 hay un atajo para el caso habitual: fetch(url, { signal: AbortSignal.timeout(3000) }) cancela la petición pasados esos milisegundos.
Cuando salta, fetch rechaza con un error cuyo name es 'TimeoutError'. Si necesitas cancelar por tu cuenta —por ejemplo, porque el usuario cerró la conexión (lección 04-05)—, usa el controlador completo:
const controlador = new AbortController();
// Si el cliente que nos pidió la pagina se va, cancelamos la llamada externa.
req.on('close', () => controlador.abort());
const respuesta = await fetch(url, { signal: controlador.signal });Y si necesitas ambas cosas, AbortSignal.any([...]) combina señales: se cancela con la que ocurra primero.
try {
const respuesta = await fetch(url, { signal: AbortSignal.timeout(3000) });
// ...
} catch (error) {
if (error.name === 'AbortError') return; // lo cancelamos nosotros: nadie escucha
if (error.name === 'TimeoutError') {
throw errorDominio('SERVICIO_EXTERNO_CAIDO', 'La API de divisas no respondio a tiempo');
}
throw error;
}La regla, sin excepciones: toda llamada saliente lleva tiempo límite. Tres segundos es un punto de partida razonable para una API de datos; si el proveedor necesita más, es una decisión consciente, no un descuido.
- Reintentos: qué es seguro repetir y qué no
Un fallo de red puede ser pasajero. Reintentar tiene sentido... para algunas cosas. Reutilizamos reintentar de la lección 02-04, con su espera exponencial, y le damos el criterio adecuado.
Lo primero es el método. Los métodos idempotentes (lección 04-05) se pueden repetir sin consecuencias; POST, no:
| Método | ¿Reintentar? | Riesgo si lo haces |
|---|---|---|
GET, HEAD |
Sí, siempre | Ninguno: solo lees |
PUT, DELETE |
Sí | Ninguno: el estado final es el mismo |
POST |
Solo con clave de idempotencia | Cobrar dos veces, crear dos pedidos |
Lo segundo es la causa. No todos los fallos mejoran esperando:
| Respuesta | ¿Reintentar? | Por qué |
|---|---|---|
Fallo de red, DNS, ECONNRESET |
Sí | Suele ser transitorio |
408, 429 |
Sí, respetando Retry-After |
El servidor pide expresamente que esperes |
500, 502, 503, 504 |
Sí | Problema del otro lado, a menudo pasajero |
400, 401, 403, 404, 422 |
No | Tu petición está mal: repetirla dará lo mismo |
// Reintentar solo lo que puede mejorar esperando.
function esReintentable(error) {
if (error.name === 'TimeoutError') return true;
if (error.codigo !== 'SERVICIO_EXTERNO_CAIDO') return true; // fallo de red
const estado = error.estadoExterno;
return estado === 408 || estado === 429 || (estado >= 500 && estado <= 599);
}
const datos = await reintentar(() => pedirTipos(), {
intentos: 3,
esperaInicialMs: 250, // 250, 500, 1000 ms
factor: 2,
esReintentable
});Reintentar un 401 es tirar tiempo y triplicar la carga de un servicio que ya te ha dicho que tu clave está mal. Y hay un peligro mayor: si tu API cae y todos tus clientes reintentan a la vez, la avalancha impide que se recupere. Por eso los reintentos van con espera exponencial y, en sistemas grandes, con un poco de aleatoriedad (jitter) para que no coincidan.
- Qué hay debajo:
http.request
http.requestfetch es cómodo, pero conviene ver el mecanismo al menos una vez. http.request (y https.request) es la API original: devuelve un stream de escritura para el cuerpo de la petición y te entrega un stream de lectura con la respuesta.
const https = require('node:https');
function pedirJson(url) {
return new Promise((resolver, rechazar) => {
const peticion = https.request(url, { method: 'GET', timeout: 3000 }, (respuesta) => {
// respuesta es un IncomingMessage: el MISMO objeto que recibe un servidor.
const trozos = [];
respuesta.on('data', (trozo) => trozos.push(trozo));
respuesta.on('end', () => {
// Acumular Buffer y decodificar al final: leccion 04-05.
const texto = Buffer.concat(trozos).toString('utf8');
try { resolver({ estado: respuesta.statusCode, datos: JSON.parse(texto) }); }
catch (error) { rechazar(error); }
});
});
peticion.on('timeout', () => peticion.destroy(new Error('Tiempo limite agotado')));
peticion.on('error', rechazar);
peticion.end(); // OBLIGATORIO: sin end() la peticion no se envia
});
}Lo revelador es la simetría: el IncomingMessage que recibes como cliente es de la misma clase que el req que recibe tu servidor, y la petición que envías se escribe igual que una respuesta. Cliente y servidor son el mismo mecanismo mirado desde los dos lados.
También se ve lo que fetch te ahorra: promesas, acumulación del cuerpo, parseo, redirecciones, decompresión. Usa http.request cuando necesites control de streams de verdad —descargar un fichero enorme directamente a disco con pipeline, sin pasar por memoria— o cuando trabajes con una API que exija algo muy particular del socket.
src/servicios/cambio-divisas.js: caché y degradación elegante
src/servicios/cambio-divisas.js: caché y degradación eleganteTodo junto, en el caso real de Escena Viva. Dos requisitos gobiernan el diseño:
- No llamar a la API externa en cada petición. Los tipos de cambio varían poco; consultarlos mil veces por minuto es absurdo, lento y probablemente te gane un
429. Se cachean en memoria con caducidad. - No romper la página si el proveedor falla. Una cartelera sin precios en libras sigue siendo útil; una cartelera con un
503no sirve para nada. A esto se le llama degradación elegante.
// src/servicios/cambio-divisas.js
// Consulta tipos de cambio en una API externa, con cache en memoria,
// tiempo limite, reintentos y degradacion elegante.
const { reintentar } = require('../utiles/reintentar.js');
const URL_BASE = process.env.URL_DIVISAS ?? 'https://api.ejemplo.test/tipos';
const TIEMPO_LIMITE_MS = 3000;
const VIDA_CACHE_MS = 60 * 60 * 1000; // 1 hora: los tipos varian poco
// Cache de proceso: { GBP: { tipo, expiraEn } }. Se pierde al reiniciar,
// que es aceptable aqui. En varios procesos haria falta Redis (M10).
const cache = new Map();
async function pedirTipo(divisa) {
const url = `${URL_BASE}?base=EUR&destino=${encodeURIComponent(divisa)}`;
const respuesta = await fetch(url, {
signal: AbortSignal.timeout(TIEMPO_LIMITE_MS),
headers: { Accept: 'application/json' }
});
if (!respuesta.ok) {
const fallo = errorDominio('SERVICIO_EXTERNO_CAIDO', `La API de divisas respondio ${respuesta.status}`);
fallo.estadoExterno = respuesta.status;
throw fallo;
}
const datos = await respuesta.json();
const tipo = Number(datos?.tipos?.[divisa]);
// Nunca confies en la forma de una respuesta ajena: validala.
if (!Number.isFinite(tipo) || tipo <= 0) {
throw errorDominio('SERVICIO_EXTERNO_CAIDO', `Tipo de cambio no valido para ${divisa}`);
}
return tipo;
}
// Devuelve el tipo EUR -> divisa, usando la cache si sigue vigente.
async function obtenerTipoCambio(divisa) {
const guardado = cache.get(divisa);
if (guardado && guardado.expiraEn > Date.now()) return guardado.tipo;
const tipo = await reintentar(() => pedirTipo(divisa), {
intentos: 3,
esperaInicialMs: 250,
esReintentable: (error) => error.name === 'TimeoutError' ||
[408, 429].includes(error.estadoExterno) || error.estadoExterno >= 500
});
cache.set(divisa, { tipo, expiraEn: Date.now() + VIDA_CACHE_MS });
return tipo;
}
// Convierte centimos de euro a centimos de otra divisa. Todo entero.
function convertirCentimos(centimosEuro, tipo) {
return Math.round(centimosEuro * tipo);
}
// DEGRADACION ELEGANTE: si el servicio falla, se devuelven solo euros
// y se anota el motivo. La pagina sigue funcionando.
async function preciosDeSesion(sesion, divisa = 'GBP') {
const precios = { EUR: sesion.precioCentimos };
try {
precios[divisa] = convertirCentimos(sesion.precioCentimos, await obtenerTipoCambio(divisa));
} catch (error) {
console.error(`[divisas] sin conversion a ${divisa}: ${error.message}`);
precios.aviso = `Precio en ${divisa} no disponible temporalmente`;
}
return precios;
}
module.exports = { obtenerTipoCambio, convertirCentimos, preciosDeSesion, cache };Cuatro decisiones que merecen subrayarse:
- El
try/catchestá enpreciosDeSesion, no enobtenerTipoCambio. La función de bajo nivel lanza —es lo correcto: no puede decidir por su cuenta qué es aceptable—. Quien conoce el contexto, y por tanto sabe que se puede vivir sin libras, es quien captura. - La respuesta ajena se valida. Que la API devuelva
200no garantiza que el cuerpo tenga la forma esperada. UnNumber.isFinitede más te ahorra unNaNpropagándose hasta un precio. - El dinero sigue siendo entero.
Math.roundsobre céntimos: 2500 céntimos por un tipo de 0.84 son 2100 céntimos, nunca21.0000000003. - La caché es de proceso. Con varios procesos (
cluster, Módulo 10) cada uno tendría la suya, lo que es aceptable para tipos de cambio y no lo sería para datos con estado. Ahí es donde entra Redis en la lección 10-03.
Comprobación con un proveedor inventado que no existe:
URL_DIVISAS=https://no-existe.test/tipos node -e "
const { preciosDeSesion } = require('./src/servicios/cambio-divisas.js');
preciosDeSesion({ precioCentimos: 2500 }).then((p) => console.log(JSON.stringify(p, null, 2)));
"
# {"EUR": 2500, "aviso": "Precio en GBP no disponible temporalmente"}El servicio externo está muerto y la respuesta sigue siendo útil. Eso es degradar bien.
- Claves de API y reúso de conexiones
Una clave de API nunca se escribe en el código. Ni "temporalmente", ni "solo para probar". El código acaba en un repositorio, el repositorio acaba compartido, y hay robots recorriendo GitHub en busca de exactamente eso. Las claves se leen del entorno:
const CLAVE = process.env.CLAVE_DIVISAS;
// Fallar en el ARRANQUE, no en la primera peticion de un usuario.
if (!CLAVE) throw new Error('Falta la variable de entorno CLAVE_DIVISAS');Comprobar la configuración al arrancar y no en la primera llamada es una diferencia importante: prefieres que el despliegue falle de inmediato antes de que falle silenciosamente el martes por la tarde. El manejo completo de la configuración —.env, secretos, entornos— es la lección 11-01.
Sobre el reúso de conexiones: abrir una conexión HTTPS es caro. Hay que hacer el saludo TCP (un viaje de ida y vuelta) y la negociación TLS (dos más). En una API a 80 ms de distancia, eso son unos 240 ms antes de enviar un solo byte útil. Si haces mil llamadas y abres mil conexiones, tiras cuatro minutos en saludos.
La solución es keep-alive: mantener la conexión abierta y reutilizarla. Node lo hace por ti en dos sitios:
fetchusaundicipor debajo, que mantiene un pool de conexiones conkeep-aliveactivado por defecto. No tienes que hacer nada.http.requestusahttp.globalAgent, que desde Node 19 también traekeepAlive: true. En versiones anteriores había que crear el agente a mano:
// Un agente con keep-alive, compartido por todas las llamadas al mismo servicio.
const agente = new https.Agent({ keepAlive: true, maxSockets: 50 });
https.request(url, { agent: agente }, manejarRespuesta);Lo que sí debes evitar es lo contrario: crear un agente nuevo por petición, que anula todo el beneficio. Y si necesitas control fino sobre el pool —límite de conexiones por destino, tiempos de vida—, undici expone su Agent directamente. Volveremos sobre esto al medir rendimiento en la lección 10-04.
Errores Comunes y Consejos
- Suponer que
fetchrechaza ante un404o un500. No lo hace. Compruebaresponse.oksiempre, y traduce el fallo a tu vocabulario de dominio. - Llamar sin tiempo límite. Es la vía más rápida a que un tercero lento tumbe tu servidor.
AbortSignal.timeout(3000)en toda llamada saliente. - Pasar un objeto en
bodysinJSON.stringify. Envías[object Object]y recibes un400desconcertante. Y acuérdate delContent-Type. - Leer el cuerpo dos veces.
TypeError: Body is unusable. Leetext()una vez y parsea tú si necesitas ambas cosas. - Reintentar un
POSTsin clave de idempotencia, o reintentar un400/401. Lo primero duplica cobros; lo segundo es carga inútil. - Cachear sin caducidad, o cachear también las respuestas de error. La primera te deja con datos viejos para siempre; la segunda convierte un fallo puntual en un fallo de una hora.
- Poner claves de API en el código. Al entorno, y comprobadas en el arranque.
- Consejo: valida la forma de lo que devuelve una API ajena antes de usarlo —un
200no es garantía de nada— y decide siempre, explícitamente, qué pasa si el servicio externo falla. Degradar suele ser mejor que propagar el error, pero es una decisión de negocio, no técnica.
Ejercicios
Ejercicio 1: comprobar que fetch no rechaza
Con el servidor de Escena Viva arrancado, escribe src/laboratorio/probar-fetch.js que pida /eventos (200), /eventos/evt-999 (404), /no-existe (404) y http://localhost:9999/ (sin servidor), todo dentro de un mismo try/catch. Imprime para cada una si la promesa se resolvió o se rechazó, el ok, el status y el error.name. Explica cuál es la única que entra en el catch y por qué.
Ejercicio 2: medir el efecto de la caché
Añade a cambio-divisas.js un contador de llamadas reales a la API y expón GET /divisas/estadisticas con { llamadas, aciertosCache, tamanoCache }. Lanza 50 peticiones seguidas a una ruta que use preciosDeSesion y comprueba cuántas llegan al proveedor. Después baja VIDA_CACHE_MS a 100 ms, repite y explica la diferencia.
Ejercicio 3: tiempo límite y degradación
Monta un servidor de pruebas en el puerto 4000 cuya única ruta espere 5 segundos antes de responder (usando dormir). Apunta URL_DIVISAS a él y comprueba que preciosDeSesion devuelve el precio en euros con su aviso al cabo de unos 3 segundos, no de 5. Mide el tiempo real con console.time y explica por qué no son exactamente 3000 ms si hay reintentos activados.
Soluciones
Solución 1. Solo la última entra en el catch: es la única en la que no ha habido respuesta.
const urls = ['http://localhost:3000/eventos', 'http://localhost:3000/eventos/evt-999',
'http://localhost:3000/no-existe', 'http://localhost:9999/'];
for (const url of urls) {
try {
const respuesta = await fetch(url);
console.log(`${url.padEnd(40)} | resuelta | ok=${respuesta.ok} | status=${respuesta.status}`);
} catch (error) {
console.log(`${url.padEnd(40)} | RECHAZADA | ${error.name}: ${error.cause?.code ?? error.message}`);
}
}Los dos 404 se resuelven con ok=false: para fetch, la petición fue un éxito. localhost:9999 rechaza con TypeError: fetch failed y un cause.code de ECONNREFUSED, porque no hay nadie escuchando. Ese es exactamente el motivo de que if (!respuesta.ok) no sea opcional.
Solución 2. Con la caché de una hora, las 50 peticiones producen una sola llamada real: la primera la trae y las 49 restantes la encuentran vigente.
const estadisticas = { llamadas: 0, aciertosCache: 0 };
const guardado = cache.get(divisa);
if (guardado && guardado.expiraEn > Date.now()) {
estadisticas.aciertosCache++;
return guardado.tipo;
}
estadisticas.llamadas++; // ...y el resto de obtenerTipoCambio, igualCon VIDA_CACHE_MS = 100, las llamadas se disparan porque casi cada petición encuentra la entrada caducada. Ahí se ve que el valor de caducidad es un equilibrio explícito entre frescura y coste: para un tipo de cambio, una hora sobra; para el aforo de una sesión, cachear siquiera sería un error.
Solución 3. El tiempo total no son 3000 ms sino unos 3000 + 250 + 3000 + 500 + 3000 ≈ 9,75 s si hay tres intentos, porque cada intento tiene su propio tiempo límite y entre ellos se espera. Es el efecto que hay que tener presente al elegir los números: el peor caso de una llamada con reintentos es la suma de todos ellos.
// El servidor lento de pruebas
require('node:http').createServer(async (req, res) => {
await dormir(5000);
res.end(JSON.stringify({ tipos: { GBP: 0.84 } }));
}).listen(4000);El precio en euros llega igualmente. Si el requisito es "nunca más de 3 segundos en total", el tiempo límite no puede vivir solo en cada intento: hay que envolver el conjunto con un AbortSignal.timeout global, o reducir los intentos. Que un reintento no sea gratis es justo la razón de que esReintentable deba ser restrictivo.
Conclusión
Escena Viva ya habla con el mundo en las dos direcciones. Como cliente, tu herramienta por defecto es fetch, global y estándar desde Node 18, con http.request por debajo para cuando necesites control de streams y clientes de terceros cuando el proyecto acumule integraciones. Y lo primero que hay que grabarse es que fetch no rechaza ante un 404 ni un 500: solo rechaza cuando no hubo respuesta. Comprobar response.ok y traducir el fallo a SERVICIO_EXTERNO_CAIDO —que la tabla de 04-02 convierte en 503— es obligatorio, igual que leer el cuerpo una sola vez y validar la forma de lo que llega.
Sabes enviar un POST con su JSON.stringify explícito y su Content-Type, y sobre todo sabes que toda llamada saliente lleva tiempo límite: AbortSignal.timeout(3000), o un AbortController completo cuando quien se va es tu propio cliente. Sin eso, la lentitud de un tercero se convierte en la caída de tu plataforma, que es la forma más común y más evitable de degradación. Los reintentos con espera exponencial reutilizan reintentar del Módulo 2 con un criterio doble: solo métodos idempotentes —POST únicamente con clave de idempotencia— y solo causas que puedan mejorar esperando: red, 408, 429 y 5xx, nunca un 400 ni un 401.
Y tienes el servicio completo: src/servicios/cambio-divisas.js, con caché en memoria con caducidad para no llamar en cada petición —una decisión de equilibrio entre frescura y coste, que en varios procesos pedirá Redis (10-03)—, conversión de dinero en céntimos enteros con Math.round, y degradación elegante: si el proveedor cae, la cartelera muestra euros y un aviso en vez de un error. Las claves de API viven en process.env y se comprueban en el arranque, y las conexiones se reutilizan con keep-alive, que fetch te da hecho gracias a undici.
Con esto cierras el Módulo 4. Escena Viva ha pasado de ser un programa de terminal a ser una plataforma web: un servidor node:http que es un EventEmitter con arranque robusto y apagado ordenado, respuestas coherentes con su tabla de estados y su traducción de error.codigo a HTTP, un enrutador propio con patrones compilados y manejo de errores centralizado, un front-end servido con streams, caché condicional y la ruta blindada contra recorrido de directorios, un POST /pedidos que valida y vende de verdad, y un cliente HTTP resistente a los fallos ajenos. Todo ello sin una sola dependencia externa: solo el núcleo de Node.
Ese "sin dependencias" ha sido deliberado, y también ha tenido un precio que has pagado a mano: enrutamiento, parseo de cuerpos, tipos MIME, caché. En el Módulo 5: NPM y Gestión de Paquetes damos el paso siguiente y aprendemos a apoyarnos en el trabajo de otros con criterio: qué es package.json, cómo se instalan y versionan las dependencias, qué significa realmente ^1.2.3, para qué sirve package-lock.json, cómo automatizar el proyecto con scripts y cómo evaluar la seguridad de lo que te traes a casa. Es el paso previo imprescindible para que, en el Módulo 6, Express sustituya a tu enrutador y reconozcas en cada una de sus piezas algo que ya has escrito tú.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
