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

  1. Node como cliente: fetch, http.request y los clientes de terceros
  2. La petición GET con fetch y el error clásico
  3. Leer la respuesta: json(), text() y las cabeceras
  4. POST con cabeceras y cuerpo JSON
  5. Tiempos límite y cancelación con AbortController
  6. Reintentos: qué es seguro repetir y qué no
  7. Qué hay debajo: http.request
  8. src/servicios/cambio-divisas.js: caché y degradación elegante
  9. Claves de API y reúso de conexiones

  1. Node como cliente: fetch, http.request y los clientes de terceros

Node 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.

  1. La petición GET con fetch y el error clásico

Una 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.

  1. Leer la respuesta: json(), text() y las cabeceras

El 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 PDF

Intentar 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.

  1. POST con cabeceras y cuerpo JSON

Enviar 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.

  1. Tiempos límite y cancelación con AbortController

Este 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.

  1. 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.

  1. Qué hay debajo: http.request

fetch 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.

  1. src/servicios/cambio-divisas.js: caché y degradación elegante

Todo 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 503 no 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/catch está en preciosDeSesion, no en obtenerTipoCambio. 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 200 no garantiza que el cuerpo tenga la forma esperada. Un Number.isFinite de más te ahorra un NaN propagándose hasta un precio.
  • El dinero sigue siendo entero. Math.round sobre céntimos: 2500 céntimos por un tipo de 0.84 son 2100 céntimos, nunca 21.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.

  1. 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:

  • fetch usa undici por debajo, que mantiene un pool de conexiones con keep-alive activado por defecto. No tienes que hacer nada.
  • http.request usa http.globalAgent, que desde Node 19 también trae keepAlive: 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 fetch rechaza ante un 404 o un 500. No lo hace. Comprueba response.ok siempre, 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 body sin JSON.stringify. Envías [object Object] y recibes un 400 desconcertante. Y acuérdate del Content-Type.
  • Leer el cuerpo dos veces. TypeError: Body is unusable. Lee text() una vez y parsea tú si necesitas ambas cosas.
  • Reintentar un POST sin clave de idempotencia, o reintentar un 400/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 200 no 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, igual

Con 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

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados