Gestión de respuestas y errores
Introducción
Cuando trabajamos con APIs externas, no basta con saber cómo enviar peticiones; es igualmente crucial saber gestionar adecuadamente las respuestas y los posibles errores que puedan surgir. Una aplicación robusta no es aquella que funciona perfectamente en condiciones ideales, sino la que sabe manejar con elegancia los casos excepcionales. En este artículo, aprenderemos técnicas esenciales para verificar el estado de las respuestas, procesar diferentes formatos de datos, implementar tiempos de espera, manejar errores de red y crear sistemas de reintentos, entre otros aspectos fundamentales para desarrollar aplicaciones que se comuniquen eficientemente con APIs externas.
Verificación de estado de respuestas
Cuando realizamos una petición con Fetch API, obtenemos un objeto Response que incluye información sobre el estado de la petición. Es fundamental verificar este estado antes de procesar los datos.
Códigos de estado HTTP
Los códigos de estado HTTP nos indican si una petición se ha completado correctamente:
- 2xx: Éxito (200 OK, 201 Created, etc.)
- 3xx: Redirección (301 Moved Permanently, 304 Not Modified, etc.)
- 4xx: Error del cliente (400 Bad Request, 401 Unauthorized, 404 Not Found, etc.)
- 5xx: Error del servidor (500 Internal Server Error, 503 Service Unavailable, etc.)
Verificación básica de respuestas
fetch('https://api.ejemplo.com/datos')
.then(respuesta => {
// Verificamos si la respuesta fue exitosa (código 200-299)
if (respuesta.ok) {
return respuesta.json(); // Procesamos la respuesta como JSON
} else {
// Si no es exitosa, lanzamos un error con el estado
throw new Error(`Error en la petición: ${respuesta.status} ${respuesta.statusText}`);
}
})
.then(datos => {
console.log('Datos recibidos:', datos);
})
.catch(error => {
console.error('Error capturado:', error.message);
});
Esta verificación es crucial porque Fetch no rechaza la promesa en caso de respuestas con códigos de error HTTP. La promesa solo se rechaza ante fallos de red.
Procesamiento de diferentes formatos
Las APIs pueden devolver datos en diferentes formatos. Los más comunes son JSON y texto plano, pero también podemos recibir otros como XML, formatos binarios o incluso HTML.
JSON
El formato más común es JSON (JavaScript Object Notation):
fetch('https://api.ejemplo.com/usuarios')
.then(respuesta => {
if (!respuesta.ok) throw new Error(`HTTP error: ${respuesta.status}`);
return respuesta.json(); // Convierte el cuerpo de la respuesta a un objeto JavaScript
})
.then(datos => {
console.log('Usuarios:', datos);
})
.catch(error => console.error('Error:', error));
Texto plano
Para APIs que devuelven texto:
fetch('https://api.ejemplo.com/mensaje')
.then(respuesta => {
if (!respuesta.ok) throw new Error(`HTTP error: ${respuesta.status}`);
return respuesta.text(); // Obtiene el cuerpo como texto
})
.then(texto => {
console.log('Mensaje:', texto);
})
.catch(error => console.error('Error:', error));
Otros formatos
Fetch API proporciona varios métodos para procesar respuestas:
- json(): Para datos JSON
- text(): Para texto plano
- blob(): Para datos binarios (imágenes, archivos)
- formData(): Para datos de formularios
- arrayBuffer(): Para datos binarios en formato ArrayBuffer
Ejemplo con async/await
Con async/await, el código queda más limpio y legible:
async function obtenerDatos() {
try {
const respuesta = await fetch('https://api.ejemplo.com/datos');
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`);
}
// Detectar el tipo de contenido
const tipoContenido = respuesta.headers.get('content-type');
if (tipoContenido && tipoContenido.includes('application/json')) {
const datos = await respuesta.json();
return datos;
} else if (tipoContenido && tipoContenido.includes('text/')) {
const texto = await respuesta.text();
return texto;
} else {
// Para otros tipos como binarios
const blob = await respuesta.blob();
return blob;
}
} catch (error) {
console.error('Error al obtener datos:', error);
throw error; // Re-lanzamos el error para manejarlo en el nivel superior
}
}
Timeout en peticiones
Fetch API no proporciona una opción directa para establecer un tiempo de espera (timeout). Sin embargo, podemos implementarlo combinando Fetch con Promise.race:
function fetchConTimeout(url, opciones, tiempo = 5000) {
// Creamos una promesa que se rechaza después del tiempo especificado
const promesaTimeout = new Promise((_, rechazar) => {
setTimeout(() => rechazar(new Error('La petición excedió el tiempo de espera')), tiempo);
});
// Usamos Promise.race para competir entre fetch y timeout
return Promise.race([
fetch(url, opciones),
promesaTimeout
]);
}
// Uso
fetchConTimeout('https://api.ejemplo.com/datos', {}, 3000)
.then(respuesta => {
if (!respuesta.ok) throw new Error(`Error HTTP: ${respuesta.status}`);
return respuesta.json();
})
.then(datos => console.log('Datos:', datos))
.catch(error => console.error('Error:', error.message));
Con async/await:
async function obtenerDatosConTimeout(url, timeout = 5000) {
try {
const resultado = await fetchConTimeout(url, {}, timeout);
if (!resultado.ok) throw new Error(`Error HTTP: ${resultado.status}`);
return await resultado.json();
} catch (error) {
console.error(`Error al obtener datos de ${url}:`, error.message);
throw error;
}
}
Manejo de errores de red
Los errores de red ocurren cuando hay problemas de conectividad, el servidor está caído, o existen problemas de DNS, entre otros. Estos errores son capturados automáticamente por el bloque catch de la promesa.
fetch('https://servidor-que-no-existe.com/datos')
.then(respuesta => respuesta.json())
.then(datos => console.log(datos))
.catch(error => {
console.error('Error de red:', error.message);
// Aquí podemos mostrar un mensaje amigable al usuario
mostrarErrorAlUsuario('No pudimos conectar con el servidor. Por favor, comprueba tu conexión a internet.');
});
function mostrarErrorAlUsuario(mensaje) {
// Función que muestra el error en la interfaz
const elementoError = document.getElementById('mensaje-error');
if (elementoError) {
elementoError.textContent = mensaje;
elementoError.style.display = 'block';
} else {
alert(mensaje); // Alternativa básica
}
}
Diferenciación de tipos de errores
Es útil diferenciar entre errores de red y errores HTTP:
async function obtenerDatos(url) {
try {
const respuesta = await fetch(url);
// Verificamos si la respuesta es correcta
if (!respuesta.ok) {
// Error HTTP (respuesta con código de error)
const mensajeError = await respuesta.text();
throw new Error(`Error HTTP ${respuesta.status}: ${mensajeError || respuesta.statusText}`);
}
return await respuesta.json();
} catch (error) {
// Aquí diferenciamos entre errores de red y otros errores
if (error instanceof TypeError && error.message.includes('fetch')) {
console.error('Error de red - No se pudo conectar con el servidor');
// Manejar específicamente errores de conectividad
} else {
console.error('Error al procesar la petición:', error.message);
// Manejar otros tipos de errores
}
throw error;
}
}
Retry logic (reintentos)
En entornos con conexiones inestables o cuando trabajamos con servicios que pueden sufrir interrupciones temporales, implementar una lógica de reintentos puede mejorar significativamente la experiencia del usuario.
Implementación básica de reintentos
async function fetchConReintentos(url, opciones = {}, maxIntentos = 3, retrasoBase = 1000) {
let ultimoError;
// Intentamos la petición varias veces
for (let intento = 0; intento < maxIntentos; intento++) {
try {
// Realizamos la petición
const respuesta = await fetch(url, opciones);
// Si la respuesta es exitosa, la devolvemos
if (respuesta.ok) return respuesta;
// Si hay un error HTTP, lo capturamos pero no reintentamos
// porque el servidor respondió, solo que con un error
throw new Error(`Error HTTP ${respuesta.status}`);
} catch (error) {
ultimoError = error;
// Solo reintentamos en caso de errores de red (no en errores HTTP)
if (!(error instanceof TypeError)) {
throw error;
}
// Si no es el último intento, esperamos antes de reintentar
if (intento < maxIntentos - 1) {
// Implementamos un retraso exponencial con un poco de aleatoriedad
const retraso = retrasoBase * Math.pow(2, intento) + Math.random() * 1000;
console.log(`Reintentando en ${Math.round(retraso / 1000)} segundos...`);
await new Promise(resolve => setTimeout(resolve, retraso));
}
}
}
// Si llegamos aquí, todos los intentos fallaron
throw ultimoError;
}
// Ejemplo de uso
fetchConReintentos('https://api.ejemplo.com/datos', {}, 3, 1000)
.then(respuesta => respuesta.json())
.then(datos => console.log('Datos obtenidos después de reintentos:', datos))
.catch(error => console.error('Todos los intentos fallaron:', error));
Reintentos con backoff exponencial
Una estrategia común es implementar un "backoff exponencial", donde el tiempo entre reintentos aumenta exponencialmente:
async function fetchConBackoffExponencial(url, opciones = {}) {
const maxIntentos = opciones.maxIntentos || 5;
const retrasoInicial = opciones.retrasoInicial || 1000; // 1 segundo
const factorBackoff = opciones.factorBackoff || 2;
const maxRetraso = opciones.maxRetraso || 30000; // 30 segundos
let intento = 0;
let ultimoError;
while (intento < maxIntentos) {
try {
return await fetch(url, opciones);
} catch (error) {
ultimoError = error;
// Calculamos el retraso para este intento
const retraso = Math.min(
retrasoInicial * Math.pow(factorBackoff, intento) + Math.random() * 1000,
maxRetraso
);
console.log(`Intento ${intento + 1} fallido. Reintentando en ${Math.round(retraso / 1000)}s`);
// Esperamos el tiempo calculado
await new Promise(resolve => setTimeout(resolve, retraso));
intento++;
}
}
throw ultimoError;
}
Feedback al usuario durante peticiones
Es importante mantener informado al usuario sobre el estado de las operaciones, especialmente aquellas que pueden tomar tiempo.
Implementación de indicadores de carga
async function cargarDatosConFeedback() {
// Mostramos el indicador de carga
const indicadorCarga = document.getElementById('indicador-carga');
if (indicadorCarga) indicadorCarga.style.display = 'block';
// Ocultamos mensajes de error anteriores
const elementoError = document.getElementById('mensaje-error');
if (elementoError) elementoError.style.display = 'none';
try {
// Realizamos la petición
const respuesta = await fetch('https://api.ejemplo.com/datos');
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`);
}
const datos = await respuesta.json();
// Procesamos los datos (actualizar la interfaz, etc.)
actualizarInterfazConDatos(datos);
return datos;
} catch (error) {
// Mostramos el error al usuario
if (elementoError) {
elementoError.textContent = `Error al cargar los datos: ${error.message}`;
elementoError.style.display = 'block';
}
console.error('Error en la petición:', error);
throw error;
} finally {
// Siempre ocultamos el indicador de carga al finalizar
if (indicadorCarga) indicadorCarga.style.display = 'none';
}
}
function actualizarInterfazConDatos(datos) {
// Función que actualiza la interfaz con los datos recibidos
const contenedor = document.getElementById('contenedor-datos');
if (!contenedor) return;
// Ejemplo: mostrar una lista de elementos
contenedor.innerHTML = '';
if (Array.isArray(datos)) {
datos.forEach(item => {
const elemento = document.createElement('div');
elemento.textContent = item.nombre || item.titulo || JSON.stringify(item);
contenedor.appendChild(elemento);
});
} else {
contenedor.textContent = JSON.stringify(datos, null, 2);
}
}
Barra de progreso para peticiones grandes
Para peticiones que involucran archivos grandes, podemos usar la API Fetch con opciones avanzadas:
async function descargarArchivoConProgreso(url) {
const barraProgreso = document.getElementById('barra-progreso');
const porcentajeTexto = document.getElementById('porcentaje');
try {
// Iniciamos la descarga
const respuesta = await fetch(url);
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`);
}
// Obtenemos el tamaño total si está disponible
const tamanoTotal = parseInt(respuesta.headers.get('Content-Length') || '0');
// Creamos un lector para procesar la respuesta por partes
const lector = respuesta.body.getReader();
// Variables para seguir el progreso
let recibido = 0;
let fragmentos = [];
// Procesamos los datos mientras llegan
while (true) {
const { done, value } = await lector.read();
if (done) {
break;
}
// Acumulamos los datos
fragmentos.push(value);
recibido += value.length;
// Actualizamos la barra de progreso si conocemos el tamaño total
if (tamanoTotal > 0 && barraProgreso && porcentajeTexto) {
const porcentaje = Math.round((recibido / tamanoTotal) * 100);
barraProgreso.value = porcentaje;
porcentajeTexto.textContent = `${porcentaje}%`;
}
}
// Concatenamos todos los fragmentos en un único Uint8Array
const datosCompletos = new Uint8Array(recibido);
let posicion = 0;
for (const fragmento of fragmentos) {
datosCompletos.set(fragmento, posicion);
posicion += fragmento.length;
}
// Convertimos a blob y devolvemos
return new Blob([datosCompletos]);
} catch (error) {
console.error('Error al descargar el archivo:', error);
throw error;
} finally {
// Aseguramos que la barra de progreso llegue al 100%
if (barraProgreso && porcentajeTexto) {
barraProgreso.value = 100;
porcentajeTexto.textContent = '100%';
}
}
}
// Ejemplo de uso
document.getElementById('boton-descarga').addEventListener('click', async () => {
try {
const archivo = await descargarArchivoConProgreso('https://ejemplo.com/archivo-grande.zip');
// Creamos un enlace para descarga
const url = URL.createObjectURL(archivo);
const enlace = document.createElement('a');
enlace.href = url;
enlace.download = 'archivo-descargado.zip';
document.body.appendChild(enlace);
enlace.click();
document.body.removeChild(enlace);
URL.revokeObjectURL(url);
} catch (error) {
alert(`Error en la descarga: ${error.message}`);
}
});
Cancelación de peticiones
Con la API Fetch moderna, podemos cancelar peticiones en curso utilizando la interfaz AbortController:
function peticionCancelable(url, opciones = {}) {
// Creamos un controlador de cancelación
const controlador = new AbortController();
// Añadimos la señal a las opciones de fetch
const opcionesConSenal = {
...opciones,
signal: controlador.signal
};
// Creamos la promesa fetch
const promesa = fetch(url, opcionesConSenal)
.then(respuesta => {
if (!respuesta.ok) throw new Error(`Error HTTP: ${respuesta.status}`);
return respuesta.json();
});
// Devolvemos tanto la promesa como el método para cancelar
return {
promesa,
cancelar: () => controlador.abort()
};
}
// Ejemplo de uso
let peticionActual;
document.getElementById('boton-cargar').addEventListener('click', () => {
// Si hay una petición en curso, la cancelamos
if (peticionActual) {
peticionActual.cancelar();
console.log('Petición anterior cancelada');
}
// Iniciamos una nueva petición
peticionActual = peticionCancelable('https://api.ejemplo.com/datos');
peticionActual.promesa
.then(datos => {
console.log('Datos recibidos:', datos);
peticionActual = null; // Limpiamos la referencia
})
.catch(error => {
if (error.name === 'AbortError') {
console.log('Petición cancelada por el usuario');
} else {
console.error('Error en la petición:', error);
}
peticionActual = null; // Limpiamos la referencia
});
});
// Botón para cancelar manualmente
document.getElementById('boton-cancelar').addEventListener('click', () => {
if (peticionActual) {
peticionActual.cancelar();
console.log('Petición cancelada manualmente');
} else {
console.log('No hay petición activa para cancelar');
}
});
Cancelación automática por timeout
Podemos combinar la cancelación con un timeout automático:
function fetchConTimeoutCancelable(url, opciones = {}, timeout = 5000) {
const controlador = new AbortController();
const { signal } = controlador;
// Configuramos el timeout que cancelará automáticamente
const temporizador = setTimeout(() => controlador.abort(), timeout);
// Realizamos la petición con la señal de cancelación
const promesa = fetch(url, { ...opciones, signal })
.then(respuesta => {
clearTimeout(temporizador); // Limpiamos el temporizador si la petición es exitosa
return respuesta;
})
.catch(error => {
if (error.name === 'AbortError') {
// Convertimos el error de cancelación en un error de timeout
throw new Error('La petición excedió el tiempo de espera');
}
throw error;
});
return {
promesa,
cancelar: () => {
clearTimeout(temporizador); // Limpiamos el temporizador
controlador.abort(); // Cancelamos la petición
}
};
}
Patrones robustos para comunicación con APIs
Finalmente, vamos a combinar todas las técnicas aprendidas en un patrón robusto para comunicarnos con APIs externas:
class ClienteAPI {
constructor(urlBase, opciones = {}) {
this.urlBase = urlBase;
this.opciones = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
timeout: 10000, // 10 segundos
maxIntentos: 3,
retrasoBase: 1000, // 1 segundo
...opciones
};
// Mapa para almacenar peticiones en curso
this.peticionesActivas = new Map();
}
async peticion(endpoint, metodo = 'GET', datos = null, opcionesPersonalizadas = {}) {
const url = this.urlBase + endpoint;
const controlador = new AbortController();
const idPeticion = `${metodo}:${url}:${Date.now()}`;
// Combinamos las opciones por defecto con las personalizadas
const opciones = {
...this.opciones,
...opcionesPersonalizadas,
method: metodo,
signal: controlador.signal,
};
// Agregamos los datos si es necesario
if (datos) {
if (metodo === 'GET') {
// Para GET, añadimos los datos como parámetros de consulta
const params = new URLSearchParams();
Object.entries(datos).forEach(([clave, valor]) => {
params.append(clave, valor);
});
// Añadimos los parámetros a la URL
const separador = url.includes('?') ? '&' : '?';
url = `${url}${separador}${params.toString()}`;
} else {
// Para otros métodos, enviamos los datos en el body
opciones.body = JSON.stringify(datos);
}
}
// Registramos la petición activa con su función de cancelación
const cancelarPeticion = () => controlador.abort();
this.peticionesActivas.set(idPeticion, cancelarPeticion);
// Configuramos un timeout
const temporizador = setTimeout(() => {
controlador.abort();
}, opciones.timeout);
try {
let ultimoError;
// Intentamos la petición varias veces
for (let intento = 0; intento < opciones.maxIntentos; intento++) {
try {
const respuesta = await fetch(url, opciones);
// Limpiamos el temporizador
clearTimeout(temporizador);
// Verificamos si la respuesta es exitosa
if (!respuesta.ok) {
throw new Error(`Error HTTP ${respuesta.status}: ${respuesta.statusText}`);
}
// Procesamos la respuesta según el tipo de contenido
const tipoContenido = respuesta.headers.get('content-type');
let resultado;
if (tipoContenido && tipoContenido.includes('application/json')) {
resultado = await respuesta.json();
} else if (tipoContenido && tipoContenido.includes('text/')) {
resultado = await respuesta.text();
} else {
resultado = await respuesta.blob();
}
return resultado;
} catch (error) {
ultimoError = error;
// Si es error de cancelación, no reintentamos
if (error.name === 'AbortError') {
throw new Error('La petición fue cancelada o excedió el tiempo de espera');
}
// Si es el último intento, propagamos el error
if (intento === opciones.maxIntentos - 1) {
throw error;
}
// Esperamos antes del siguiente intento con backoff exponencial
const retraso = opciones.retrasoBase * Math.pow(2, intento) + Math.random() * 1000;
console.log(`Reintento ${intento + 1}/${opciones.maxIntentos} en ${Math.round(retraso / 1000)}s...`);
await new Promise(resolve => setTimeout(resolve, retraso));
}
}
throw ultimoError; // Nunca debería llegar aquí
} finally {
// Limpiamos recursos
clearTimeout(temporizador);
this.peticionesActivas.delete(idPeticion);
}
}
// Métodos de conveniencia para los verbos HTTP comunes
async get(endpoint, params = null, opciones = {}) {
return this.peticion(endpoint, 'GET', params, opciones);
}
async post(endpoint, datos = null, opciones = {}) {
return this.peticion(endpoint, 'POST', datos, opciones);
}
async put(endpoint, datos = null, opciones = {}) {
return this.peticion(endpoint, 'PUT', datos, opciones);
}
async delete(endpoint, opciones = {}) {
return this.peticion(endpoint, 'DELETE', null, opciones);
}
// Cancelar todas las peticiones activas
cancelarTodas() {
for (const cancelar of this.peticionesActivas.values()) {
cancelar();
}
this.peticionesActivas.clear();
}
}
// Ejemplo de uso
const api = new ClienteAPI('https://api.ejemplo.com', {
headers: {
'Authorization': 'Bearer TOKEN_AQUI'
}
});
// Realizar peticiones
async function obtenerUsuarios() {
try {
const usuarios = await api.get('/usuarios');
console.log('Usuarios:', usuarios);
return usuarios;
} catch (error) {
console.error('Error al obtener usuarios:', error.message);
mostrarErrorAlUsuario('No pudimos cargar la lista de usuarios. Por favor, inténtalo de nuevo más tarde.');
throw error;
}
}
// Cancelar peticiones al navegar a otra página
window.addEventListener('beforeunload', () => {
api.cancelarTodas();
});
Resumen
En este artículo hemos explorado técnicas fundamentales para gestionar respuestas y errores al trabajar con APIs externas. Hemos aprendido a verificar correctamente el estado de las respuestas, procesar diferentes formatos de datos, implementar tiempos de espera, manejar errores de red de forma adecuada, crear sistemas de reintentos, proporcionar feedback al usuario durante las peticiones y cancelar peticiones en curso cuando sea necesario.
La gestión robusta de errores y respuestas es una parte crucial del desarrollo de aplicaciones web modernas que se comunican con servicios externos. Implementando estas técnicas, podrás crear aplicaciones más resistentes a fallos, que ofrezcan una mejor experiencia al usuario incluso cuando las condiciones no sean ideales. Recuerda que una aplicación profesional no solo funciona correctamente cuando todo va bien, sino que maneja con elegancia las situaciones excepcionales, proporcionando feedback claro al usuario y recuperándose de los errores cuando es posible.