Reintentos, Backoff e Idempotencia
Llamadas resilientes en LTE inestable y modo subway - cuándo reintentar, cómo hacer backoff, y cómo las claves de idempotencia previenen cargos duplicados.
Busca en todas las páginas de la documentación
Llamadas resilientes en LTE inestable y modo subway - cuándo reintentar, cómo hacer backoff, y cómo las claves de idempotencia previenen cargos duplicados.
Tarjeta de referencia rápida - lista para copiar y pegar.
// src/api/retry.ts
export type RetryOptions = {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
};
function jitter(ms: number): number {
return ms * (0.5 + Math.random() * 0.5);
}
export async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
opts: RetryOptions = {}
): Promise<Response> {
const {
maxAttempts = 4,
baseDelayMs = 500,
maxDelayMs = 8_000,
shouldRetry = defaultShouldRetry,
} = opts;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(input, init);
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const waitSec = retryAfter ? Number(retryAfter) : 2 ** attempt;
await sleep(jitter(waitSec * 1000));
continue;
}
if (res.ok || !shouldRetry(res, attempt)) return res;
lastError = new Error(`HTTP ${res.status}`);
} catch (error) {
lastError = error;
if (!shouldRetry(error, attempt)) throw error;
}
if (attempt < maxAttempts) {
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await sleep(jitter(delay));
}
}
throw lastError;
}
function defaultShouldRetry(error: unknown, attempt: number): boolean {
if (attempt >= 4) return false;
if (error instanceof Response) {
const status = error.status;
return status >= 500 || status === 408 || status === 429;
}
if (error instanceof Error) {
if (error.name === "AbortError") return false;
return error instanceof TypeError; // fluctuación de red
}
return false;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}Cuándo usarlo:
import NetInfo from "@react-native-community/netinfo";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { fetchWithRetry } from "../api/retry";
const API = process.env.EXPO_PUBLIC_API_URL ?? "https://api.example.com";
function idempotencyKey(): string {
return `idem-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
async function createOrder(items: { sku: string; qty: number }[], key: string) {
const res = await fetchWithRetry(
`${API}/orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ items }),
},
{
maxAttempts: 3,
shouldRetry: (err, attempt) => {
if (err instanceof Response) return err.status >= 500 && attempt < 3;
return err instanceof TypeError && attempt < 3;
},
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useCreateOrder() {
const queryClient = useQueryClient();
const waitForOnline = useCallback(async () => {
const state = await NetInfo.fetch();
if (state.isConnected && state.isInternetReachable !== false) return;
await new Promise<void>((resolve) => {
const unsub = NetInfo.addEventListener((s) => {
if (s.isConnected && s.isInternetReachable !== false) {
unsub();
resolve();
}
});
});
}, []);
return useMutation({
mutationFn: async (items: { sku: string; qty: number }[]) => {
await waitForOnline();
const key = idempotencyKey();
return createOrder(items, key);
},
retry: false, // reintentos manejados en fetchWithRetry con clave de idempotencia
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
});
}Lo que esto demuestra:
Retry-After cuando está presente.retry: false en useMutation cuando el reintento de capa de transporte ya se ejecuta con claves - evita llamadas de función de mutación duplicadas con claves nuevas.| Método / caso | ¿Reintentar? | Condición |
|---|---|---|
| GET, HEAD | ✅ Sí | Intentos acotados + backoff |
| PUT con id estable | ✅ Generalmente | El servidor upserts por id de recurso |
| DELETE | ✅ Generalmente | La segunda eliminación debe 404 de manera segura |
| POST de pago | ⚠️ Solo con clave de idempotencia | El servidor almacena la clave - misma respuesta |
| POST sin clave | ❌ No | Riesgo de filas/cargos duplicados |
| 401 / 403 | ❌ No | Actualizar autenticación primero |
| Validación 400 | ❌ No | La carga útil es incorrecta - arregla el cliente |
| Conflicto 409 | ❌ No | Fusionar o mostrar UI |
type IdempotentRequest = {
idempotencyKey: string; // UUID generado por el cliente
operation: "createOrder" | "capturePayment";
payload: unknown;
};Idempotency-Key - alinea el nombre del encabezado con el proveedorexport const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (failureCount >= 3) return false;
if (error instanceof Error && error.message.startsWith("HTTP 4")) return false;
return true;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
},
mutations: {
retry: 0,
},
},
});retry: 0 - idempotencia explícita en la capa de transporte en su lugar| Síntoma | Causa probable | Estrategia de reintento |
|---|---|---|
TypeError: Network request failed | Transición, túnel, avión | Backoff; pausa si está sin conexión |
| Se cuelga y luego expira | Señal débil | AbortController + retry |
| HTTP 502/503 | Recuperación de puerta de enlace | Reintentar con jitter |
| 200 intermitente + cuerpo vacío | Problema de CDN | Protección de análisis; reintentar una vez |
| Éxito en el servidor, agotamiento de tiempo en el cliente | ACK lento | Clave de idempotencia en POST |
for (const item of queue) {
try {
await fetchWithRetry(url, { headers: { "Idempotency-Key": item.key }, ... });
dequeue(item);
} catch {
incrementAttempts(item);
if (item.attempts > 5) surfaceToUser(item);
}
}| Alternativa | Usar cuándo | No usar cuándo |
|---|---|---|
Transporte fetchWithRetry | Cliente fetch personalizado | Ya usando axios-retry con las mismas reglas |
TanStack Query retry | Consultas GET | Mutaciones no idempotentes |
| Reproducción de TaskManager en segundo plano | Cargas grandes horas después | Se requiere retroalimentación inmediata del usuario |
| Long-polling del servidor | Presupuesto de reintento del cliente agotado | REST normal con tiempos de espera cortos |
3-4 para lecturas con backoff exponencial. 0-1 para mutaciones a menos que se garanticen claves de idempotencia.
Aleatorizar retraso (por ejemplo, 500-1000 ms en lugar de exactamente 750 ms) para que miles de dispositivos no reintentan en el mismo milisegundo cuando una torre regresa.
No - el recurso está faltando o la URL es incorrecta. Reintentar desperdicia batería y oculta errores.
Las claves de idempotencia le dicen al servidor que deduplique dentro de un TTL. Los IDs del cliente se convierten en el id del recurso en la carga útil - usa ambos para crear sin conexión cuando sea posible.
Puede, si se configura con las mismas reglas de idempotencia y estado. Las utilidades independientes del transporte mantienen fetch y axios consistentes.
Versiones de stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0 y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026