Skip to content

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.

Academia Web — aprende, copia y construye.