Fetch API, POA funcional, patrones de diseño y testing: todo lo que hay detrás del código
Cada sección explica un concepto y lo conecta con la línea real de código de smartfetch donde se usa, para poder defenderlo frente al profesor sin depender de memorizar el código de memoria.
Lo que hace fetch por debajo, y por qué hace falta construir encima de él.
fetch nativo no tiene timeout ni reintentos por defecto, a diferencia de axios. El enunciado pide construir eso encima de fetch, sin dependencias de terceros, para no heredar la superficie de ataque de una librería externa (axios arrastra decenas de dependencias transitivas; cada una es un vector de supply-chain).
SmartFetch es un wrapper: añade timeout, retry y una API más cómoda, pero por debajo sigue siendo fetch puro.
fetch(url, options) devuelve una Promise<Response>. Puntos clave:
fetch solo rechaza la promesa por fallos de red (DNS, conexión rechazada, CORS, abort). Un 500 es una respuesta válida, no un error de red. Por eso doRequest.ts chequea response.ok manualmente y lanza HttpError a mano — fetch no lo hace por ti.parseBody siempre lee como texto y luego intenta JSON.parse, en vez de intentar .json() y caer a .text() (que fallaría porque el stream ya se consumió en el primer intento).Response.headers es un objeto Headers, no un objeto plano — de ahí que SmartFetchResponse.headers esté tipado como Headers y no como Record<string, string>.Son la misma cosa en dos sintaxis distintas: async/await es azúcar sintáctico sobre .then()/.catch() — el compilador de TypeScript transpila uno al otro. Por eso el enunciado pide "soportar ambos" y la respuesta correcta es no construir nada especial: cualquier función que devuelva una Promise ya funciona con await y con .then() al mismo tiempo. Es la razón de que example.ts deba mostrar un ejemplo con cada sintaxis usando la misma función pública — no son dos rutas de código distintas.
Es el mecanismo nativo del navegador/Node para cancelar una petición en curso:
new AbortController() crea un controller con una propiedad .signal.controller.signal como options.signal a fetch(). Mientras la petición está en vuelo, fetch escucha ese signal.controller.abort() dispara el evento 'abort' — fetch lo detecta y rechaza su promesa.El patrón real en withTimeout.ts:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
const timeout = new Promise((_resolve, reject) => {
controller.signal.addEventListener('abort', () =>
reject(new TimeoutError(...)));
});
return Promise.race([fn(url, { ...options, signal: controller.signal }), timeout])
.finally(() => clearTimeout(timer));
El truco de diseño: en vez de esperar a que fetch rechace con su propio AbortError genérico e inspeccionarlo, se corre una carrera (Promise.race) entre la petición real y una promesa que se rechaza con TimeoutError en cuanto el signal se dispara. Gana quien resuelva/rechace primero, así el código nunca depende de cómo fn reporte la cancelación. .finally() limpia el timer sin importar quién ganó.
Por qué la solución se llama Decorator + Strategy, y qué significa realmente "aspectos" aquí.
Una función que recibe una función y devuelve otra función con la misma firma, añadiendo comportamiento sin tocar la original. withTimeout(fn, ms) y withRetry(fn, policy) son decoradores: ambos entran y salen con el tipo RequestFn. Por eso se pueden anidar libremente — cada capa no sabe que la otra existe, solo sabe que envuelve "una función que hace una petición". Es la versión ligera del Decorator de POO (normalmente interface Component + ConcreteComponent + Decorator implements Component) sin la ceremonia de clases, porque TS/JS tiene funciones de primera clase.
La decisión "¿es reintentable este error?" no está hardcodeada dentro de withRetry con un if. Está inyectada como una función intercambiable: RetryPolicy.isRetryable: (error) => boolean. Cualquiera puede pasar su propia estrategia sin tocar el código de withRetry. Eso es Strategy: el algoritmo de decisión es un parámetro, no una rama fija.
POA busca separar responsabilidades transversales ("cross-cutting concerns": logging, timeout, retry, autenticación) de la lógica de negocio pura, porque tienden a repetirse/mezclarse por todos lados si no se aíslan.
En Java/AspectJ esto se hace con aspectos declarativos reales: se anota un método con @Around y un compilador especial ("weaving") inyecta código antes/después/alrededor, sin que el desarrollador toque el método original.
TypeScript/JS no tiene weaving nativo. La respuesta equivalente es la composición de funciones de orden superior: cada aspecto es una función que envuelve a la siguiente. doRequest (núcleo: "hacer la petición") no sabe que existe timeout ni retry. withTimeout le añade el aspecto "límite de tiempo". withRetry le añade "tolerancia a fallas". Ninguna de las tres conoce a las otras dos, y esa incomunicación es la prueba de que el cross-cutting concern está aislado:
withRetry(withTimeout(doRequest, timeout), policy)
withLogging), se agrega como una capa más sin tocar doRequest, withTimeout ni withRetry. Esa extensibilidad sin modificar código existente es el Open/Closed Principle, y es la prueba práctica de que los aspectos están bien separados.
get<T>(url): Promise<SmartFetchResponse<T>> — el <T> es un placeholder de tipo que decide quien llama a la función: client.get<Usuario>('/users/1') le dice a TypeScript "esto trae un Usuario", y result.data queda tipado como Usuario en vez de unknown, con autocompletado y chequeo de tipos en el resto del código.
Sin generics, SmartFetchResponse tendría que tipar data: unknown siempre y forzar un cast manual en cada llamada — el generic mueve ese cast a un solo lugar declarado, no a cada uso. RequestFn (sin generic) es la firma interna que comparten doRequest/withTimeout/withRetry: a ese nivel no importa el tipo final del dato, es responsabilidad de SmartFetchClient aplicar el generic cuando expone get<T>.
Error (nativo de JS)
└── SmartFetchError
├── TimeoutError
├── NetworkError
└── HttpError (status, body)
extends Error conserva .message, .stack y el comportamiento de instanceof Error. this.name = this.constructor.name hace que el nombre impreso en consola sea el de la subclase real (TimeoutError) y no un genérico Error.
Por qué una jerarquía y no un solo error con un campo type: permite instanceof en el código que consume la librería sin parsear strings ni comparar campos — TypeScript hace narrowing automático dentro del if. Es también la base de la Strategy del punto 5: isRetryable es legible precisamente porque cada causa de fallo tiene su propia clase.
Semántica del protocolo, cómo se prueba sin red real, y la respuesta fundamentada sobre headers.
HttpMethod es un union type de strings literales: TS rechaza en compilación cualquier método fuera de esos 5, no hace falta validarlo en runtime.global.fetch: en vez de hacer peticiones reales (lento, no determinístico, requiere red), jest.fn().mockResolvedValue(...) reemplaza fetch por una función falsa que devuelve exactamente lo que el test necesita. Es lo que exige el enunciado con "solo pruebas unitarias": no debe tocar el mundo exterior.withTimeout necesita probar "pasaron 100ms" sin esperar 100ms reales. jest.useFakeTimers() reemplaza setTimeout/Date; jest.advanceTimersByTime(100) avanza el reloj simulado instantáneamente.restoreAllMocks, useRealTimers): evita que un mock o timer falso de un test contamine el siguiente — cada test debe poder correr solo o en cualquier orden.| Métrica | Qué cuenta |
|---|---|
| % Stmts | De todas las instrucciones ejecutables, cuántas corrió al menos un test. |
| % Branch | De cada bifurcación (if/else, ?:, ??, ||), cuántas ramas se tomaron. 100% statements no implica 100% branch. |
| % Funcs | De las funciones/métodos declarados, cuántos se invocaron al menos una vez. |
| % Lines | Igual que statements pero por línea física en vez de por instrucción. |
doRequest.ts son la rama body === undefined de resolveBody (nunca se probó una petición sin body) y el catch de JSON.parse en parseBody (nunca se probó una respuesta no-JSON). El 0% branch de errors/index.ts es el operador ?? del mensaje default de TimeoutError (nunca se probó sin pasarle message). Y falta toda la mitad de Stefano (withRetry, SmartFetchClient), sin tests todavía.
Depende de en qué momento del ciclo de vida está esa petición.
fetch() la despache (mientras el header vive en un objeto Headers o en options.headers en JS): sí. Headers expone .set(nombre, valor) (sobrescribe), .append(nombre, valor) (agrega otro valor al mismo header) y .delete(nombre), siempre que el Headers no tenga guard "immutable".fetch está en vuelo o ya se resolvió): no. No existe API para "alcanzar" bytes ya enviados por el socket y modificarlos. Lo único posible es mandar una petición nueva.withRetry: no reintenta "editando" la petición fallida, arma una llamada nueva a fn(url, options) en cada intento. Eso implica que sí se puede cambiar un header entre reintentos (ej. refrescar un token expirado antes del segundo intento) — no modificando la petición vieja, sino porque cada intento nuevo puede recibir un options.headers distinto si quien llama decide actualizarlo entre intentos. En el alcance actual del enunciado esto no se pide, pero es la respuesta correcta a "¿se puede cambiar?": se puede, a nivel de la próxima petición, no de la que ya se envió.
Caveat de seguridad si el profesor insiste: incluso con un Headers mutable, el estándar Fetch prohíbe a JavaScript setear ciertos "forbidden header names" (Content-Length, Host, Cookie, Origin, Connection, entre otros) — el navegador los controla él mismo para evitar que una página falsifique metadatos de bajo nivel del protocolo. En Node esta restricción es más laxa (no hay sandbox de navegador), pero no debe asumirse que un header prohibido en browser se puede setear igual en Node sin comprobarlo.
SmartFetchClient.get/post/put/patch/delete
│ arma la cadena de aspectos con la config del cliente
▼
withRetry(fn, policy) ← Strategy: policy.isRetryable decide si reintenta
│ (si falla y es reintentable, vuelve a llamar a fn)
▼
withTimeout(fn, ms) ← Decorator: AbortController + Promise.race
│ (si no hay ms, retorna fn intacta)
▼
doRequest(url, options) ← núcleo: fetch real + parseo + HttpError/NetworkError
Cada flecha es una llamada a función, no herencia ni composición de clases — es la razón de que todo el sistema quepa en RequestFn como único contrato compartido (puntos 5 y 6).