Tema
Fetch wrapper con manejo de errores
fetch() tiene dos trampas: no lanza error cuando el servidor responde 404 o 500, y no tiene timeout. Este wrapper lo resuelve y añade JSON automático, URL base, token de autenticación, parámetros de búsqueda y un error con información útil.
El código
js
/** Error con el estado HTTP y el cuerpo de la respuesta */
export class HttpError extends Error {
constructor(mensaje, { status = 0, datos = null, url = '' } = {}) {
super(mensaje);
this.name = 'HttpError';
this.status = status; // 0 = sin conexión o timeout
this.datos = datos; // cuerpo de la respuesta (p. ej. errores de validación)
this.url = url;
}
}
const CONFIG = {
baseURL: '/api',
timeout: 15000,
obtenerToken: () => sessionStorage.getItem('token'), // ajústalo a tu app
};
/**
* Petición HTTP con JSON, timeout y errores claros.
* @param {string} ruta '/productos' (se une a baseURL) o URL completa
* @param {object} opciones { method, body, params, headers, timeout, signal }
*/
export async function api(ruta, { method = 'GET', body, params, headers = {}, timeout = CONFIG.timeout, signal } = {}) {
// URL + query params (?pagina=2&q=cafe), omitiendo vacíos
const url = new URL(ruta.startsWith('http') ? ruta : CONFIG.baseURL + ruta, window.location.origin);
Object.entries(params ?? {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
});
// Timeout combinado con una señal externa (para cancelar desde fuera)
const controlador = new AbortController();
const temporizador = setTimeout(() => controlador.abort('timeout'), timeout);
signal?.addEventListener('abort', () => controlador.abort('cancelado'));
const esFormData = body instanceof FormData;
const token = CONFIG.obtenerToken?.();
let respuesta;
try {
respuesta = await fetch(url, {
method,
signal: controlador.signal,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(body && !esFormData && { 'Content-Type': 'application/json' }),
...(token && { Authorization: `Bearer ${token}` }),
...headers,
},
body: body === undefined ? undefined : esFormData ? body : JSON.stringify(body),
});
} catch (err) {
const motivo = controlador.signal.reason;
if (motivo === 'timeout') throw new HttpError('El servidor tardó demasiado en responder', { url: url.href });
if (motivo === 'cancelado') throw new HttpError('Petición cancelada', { url: url.href });
throw new HttpError('No hay conexión a internet o el servidor no responde', { url: url.href });
} finally {
clearTimeout(temporizador);
}
// Lee el cuerpo según su tipo
const tipo = respuesta.headers.get('content-type') ?? '';
const datos = respuesta.status === 204 ? null
: tipo.includes('application/json') ? await respuesta.json().catch(() => null)
: await respuesta.text();
if (!respuesta.ok) {
const mensaje = (datos && (datos.error || datos.message)) || mensajePorEstado(respuesta.status);
throw new HttpError(mensaje, { status: respuesta.status, datos, url: url.href });
}
return datos;
}
function mensajePorEstado(status) {
return {
400: 'La solicitud no es válida',
401: 'Tu sesión expiró, vuelve a iniciar sesión',
403: 'No tienes permiso para realizar esta acción',
404: 'No se encontró lo que buscabas',
409: 'Hay un conflicto con los datos enviados',
422: 'Revisa los datos del formulario',
429: 'Demasiadas solicitudes, espera un momento',
500: 'Error del servidor, intenta más tarde',
503: 'Servicio no disponible temporalmente',
}[status] ?? `Error inesperado (${status})`;
}
// Atajos
api.get = (ruta, params, opc) => api(ruta, { ...opc, params });
api.post = (ruta, body, opc) => api(ruta, { ...opc, method: 'POST', body });
api.put = (ruta, body, opc) => api(ruta, { ...opc, method: 'PUT', body });
api.patch = (ruta, body, opc) => api(ruta, { ...opc, method: 'PATCH', body });
api.delete = (ruta, opc) => api(ruta, { ...opc, method: 'DELETE' });Ejemplos de uso
js
import { api, HttpError } from './api.js';
// GET con parámetros: /api/productos?pagina=2&q=café
const { data, meta } = await api.get('/productos', { pagina: 2, q: 'café' });
// POST con manejo de errores de validación
try {
const nuevo = await api.post('/productos', { nombre: 'Taza', precio: 95 });
mostrarAviso(`Producto ${nuevo.data.nombre} creado`);
} catch (err) {
if (err instanceof HttpError && err.status === 422) {
// Pinta los errores junto a cada campo
err.datos.detalles?.forEach(({ campo, mensaje }) => marcarCampo(campo, mensaje));
} else if (err.status === 401) {
window.location.href = '/login.html';
} else {
mostrarAviso(err.message, 'error');
}
}
// Subir un archivo (FormData: no se pone Content-Type)
const fd = new FormData(document.querySelector('#form-foto'));
await api.post('/archivos/avatar', fd);
// Cancelar la petición anterior si el usuario sigue escribiendo
let control;
async function buscar(texto) {
control?.abort();
control = new AbortController();
try {
return await api.get('/buscar', { q: texto }, { signal: control.signal });
} catch (err) {
if (err.message !== 'Petición cancelada') throw err;
}
}
// Varias peticiones en paralelo
const [categorias, destacados] = await Promise.all([
api.get('/categorias'),
api.get('/productos', { destacado: 1 }),
]);Reintentos automáticos (opcional)
Útil para redes móviles inestables. Sólo reintenta errores de red o 5xx, nunca 4xx.
js
export async function conReintentos(fn, { intentos = 3, espera = 500 } = {}) {
for (let i = 1; ; i++) {
try {
return await fn();
} catch (err) {
const reintentable = err.status === 0 || err.status >= 500;
if (!reintentable || i >= intentos) throw err;
await new Promise((r) => setTimeout(r, espera * 2 ** (i - 1))); // 500, 1000, 2000 ms
}
}
}
const datos = await conReintentos(() => api.get('/productos'));Error común
Hacer const datos = await res.json() sin comprobar res.ok. Si el servidor devolvió un error HTML (por ejemplo, una página 500 de Apache), res.json() explota con Unexpected token <. El wrapper revisa el tipo de contenido antes de parsear.
Buena práctica
Centraliza todas las llamadas en un archivo api.js. Si mañana cambia la URL de la API o el método de autenticación, lo cambias en un solo lugar.