Un timeout o una conexión interrumpida en JSON-RPC no significa que la solicitud haya fallado: el nodo puede haber aplicado ya tu llamada que modifica el estado, por lo que un reintento ingenuo puede duplicar una transferencia, un mint o una orden. JSON-RPC 2.0 no tiene una clave de idempotencia integrada; el campo id solo correlaciona una respuesta y no deduplica en el servidor. Los reintentos seguros provienen de primitivas nativas de la cadena: el nonce de la cuenta EVM, el hash de transacción determinista que puedes consultar con eth_getTransactionReceipt, las firmas de Solana con nonces duraderos y los client-order-ids de los exchanges. Construye un cliente at-most-once persistiendo un id de operación determinista junto con el payload firmado antes de enviar, registrando el hash resultante y comprobando si ese hash ya se confirmó antes de volver a difundir.
Por qué una escritura JSON-RPC que expiró no es una escritura fallida
El riesgo principal al reintentar escrituras JSON-RPC es una suposición falsa: que un timeout, una conexión reiniciada o una respuesta 5xx significa que el nodo no aplicó tu solicitud. En la práctica, la solicitud puede haber sido aceptada, firmada y difundida, y solo se perdió la respuesta de vuelta. El cliente ve un error; la cadena ve una transacción. Reintentar a ciegas produce entonces una segunda aplicación: dos transferencias, dos mints, dos órdenes.
Este es el clásico bug de producción detrás de pagos duplicados y órdenes ejecutadas dos veces. No es específico de ningún proveedor; es una propiedad de cualquier protocolo de solicitud/respuesta sobre una red no confiable. La guía Errores de timeout en RPC: causas y soluciones cubre el lado del transporte; este artículo cubre el lado de la corrección, que es lo que detiene la escritura duplicada.
La regla práctica: para métodos de lectura, reintenta libremente. Para métodos que modifican el estado, trata cada resultado ambiguo como 'posiblemente aplicado' hasta que hayas leído el estado de la cadena para probar lo contrario. Esa única disciplina previene la mayoría de los incidentes de escritura duplicada.
- Timeout o conexión reiniciada = resultado desconocido, no fallo.
- El nodo puede haber aplicado la llamada y perdido solo la respuesta.
- Un reintento ingenuo de una escritura puede duplicar el efecto.
- La resolución requiere leer el estado de la cadena, no reenviar.
Qué garantiza realmente JSON-RPC 2.0 sobre el campo id
La especificación JSON-RPC 2.0 define id como un identificador de solicitud usado para correlacionar una respuesta con su solicitud. Es un valor elegido por el cliente, y la especificación no dice nada sobre que el servidor lo use para deduplicar trabajo. Reutilizar el mismo id en un reintento no impide una segunda aplicación; solo te ayuda a emparejar la respuesta con la llamada que hiciste.
La especificación también define notificaciones: solicitudes sin id, para las que el servidor no devuelve respuesta. Las notificaciones son de tipo fire-and-forget y son estrictamente peores para la seguridad de reintentos, porque ni siquiera puedes correlacionar un resultado. Para llamadas que modifican el estado, envía siempre un id y espera siempre una respuesta sobre la que puedas razonar.
Como JSON-RPC no tiene un concepto de clave de idempotencia, la convención HTTP de la cabecera Idempotency-Key no forma parte del protocolo base. Algunas pasarelas RPC con frontend HTTP pueden soportarla, pero no puedes asumirlo. La referencia autorizada es la especificación JSON-RPC 2.0; la convención HTTP de idempotencia se describe en el borrador de la IETF sobre la cabecera Idempotency-Key.
- id correlaciona una respuesta; no deduplica en el servidor.
- Las notificaciones (sin id) no devuelven respuesta y son inseguras para escrituras.
- JSON-RPC 2.0 no tiene clave de idempotencia nativa.
- Idempotency-Key HTTP es una convención aparte, no garantizada por los nodos RPC.
Una taxonomía de seguridad: métodos de lectura vs métodos que modifican el estado
No todos los métodos JSON-RPC conllevan el mismo riesgo de reintento. Los métodos de lectura son naturalmente idempotentes: llamar a eth_call, eth_getBalance o getAccountInfo dos veces devuelve el mismo resultado para el mismo contexto de bloque y no cambia nada. Los métodos que modifican el estado no son idempotentes: eth_sendRawTransaction, sendTransaction y los endpoints de órdenes de exchanges aplican un efecto cada vez que tienen éxito.
Usa esta tabla de decisión antes de escribir cualquier lógica de reintento. La columna de razonamiento es la parte que importa: te dice por qué un método es seguro o inseguro, para que puedas clasificar nuevos métodos tú mismo en lugar de memorizar una lista.
En caso de duda, clasifica un método como modificador del estado. El coste de una comprobación innecesaria son unas pocas lecturas extra; el coste de un reintento incorrecto es un efecto financiero duplicado.
- eth_call, eth_getBalance, eth_getTransactionReceipt, getAccountInfo: idempotentes, seguros de reintentar.
- eth_sendRawTransaction, sendTransaction, envío de órdenes en exchange: no idempotentes, nunca reintentar a ciegas.
- eth_getTransactionCount: lectura idempotente, pero su valor determina la corrección del nonce.
- Solicitudes por lotes: seguridad mixta; un fallo parcial obliga a razonar por sub-solicitud.
Primitivas de idempotencia nativas de la cadena que ya tienes
No necesitas una clave de idempotencia personalizada para que las escrituras se puedan reintentar de forma segura; las cadenas proporcionan primitivas. En cadenas EVM, el nonce de la cuenta hace que una transacción de reemplazo con el mismo nonce sea una cancelación-o-reemplazo idempotente en lugar de una segunda aplicación. El hash de transacción es una identidad determinista, por lo que un cliente puede consultar eth_getTransactionReceipt por hash en lugar de reenviar a ciegas. La guía Gestión de nonces EVM bajo concurrencia cubre las carreras de nonces en profundidad.
En Solana, deduplica mediante la firma de la transacción y usa un nonce duradero o una comprobación de blockhash fresco para controlar si una transacción re-firmada puede confirmarse. La guía Timeouts y reintentos en RPC de Solana cubre el comportamiento de timeout que hace esto necesario.
Las APIs de exchanges y de estilo Hyperliquid exponen un patrón de client-order-id (cloid): tú proporcionas un id de cliente único y el venue rechaza o ignora un duplicado. Eso es lo más parecido a una clave de idempotencia real en APIs de trading, y es por lo que siempre deberías configurarla cuando el venue la soporte.
- EVM: el reemplazo con el mismo nonce es cancelar-o-reemplazar, no una segunda aplicación.
- EVM: el hash de transacción es determinista; consulta el recibo por hash.
- Solana: deduplica por firma; el nonce duradero o el blockhash controlan la confirmación.
- Exchanges: client-order-id / cloid es la clave de deduplicación a nivel de venue.
Construir un cliente at-most-once: persistir, enviar, registrar, comprobar
Un cliente at-most-once sigue cuatro pasos. Primero, genera un id de operación determinista a partir de tu intención de negocio (por ejemplo, un hash de cuenta, destinatario, importe y una referencia de negocio), no un UUID aleatorio por intento. Segundo, persiste ese id de operación junto con el payload firmado antes de enviar nada. Tercero, al tener éxito, registra el hash de transacción de la cadena resultante asociado al id de operación. Cuarto, al reintentar, comprueba primero si el hash persistido ya se confirmó antes de volver a difundir.
El paso de persistencia es lo que hace que el patrón sobreviva a un fallo del proceso. Si solo mantienes el estado en memoria, un reinicio pierde el hash y vuelves a adivinar. Un pequeño almacén duradero (una fila de base de datos, un archivo, incluso un almacén clave-valor) es suficiente.
El paso de comprobación es una lectura: eth_getTransactionReceipt por hash en EVM, getSignatureStatuses en Solana, o una consulta de estado de orden en un exchange. Si el recibo existe, la escritura ya se aplicó; no reenvíes. Si no existe y el nonce sigue sin usarse, puedes volver a difundir de forma segura el mismo payload firmado.
- Id de operación determinista derivado de la intención de negocio, no aleatorio por intento.
- Persiste id + payload firmado antes de enviar.
- Registra el hash de tx de la cadena al tener éxito.
- Al reintentar, comprueba primero el hash; vuelve a difundir solo si no se confirmó.
Ejemplo ejecutable en Node.js: verificar-antes-de-reenviar evita una escritura doble
El ejemplo siguiente envía una vez, registra el hash, simula un timeout y luego demuestra la verificación-antes-de-reenviar. Usa una URL RPC de marcador de posición; apúntala a un endpoint de testnet como la página de la red Ethereum o cualquier endpoint de la guía de endpoints RPC (RPC Assistant). El comportamiento clave es que la ruta de reintento lee el recibo antes de reenviar.
Esto es deliberadamente mínimo. En producción reemplazarías el almacén en memoria por uno duradero y añadirías gestión de nonces, pero el flujo de control es el mismo.
// check-then-resend.js — Node 18+, no external deps beyond ethers
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
const RPC_URL = process.env.RPC_URL; // e.g. a testnet endpoint
const provider = new JsonRpcProvider(RPC_URL);
const wallet = new Wallet(process.env.PRIVATE_KEY, provider);
// In-memory stand-in for a durable store.
const store = new Map();
async function sendOnce(opId, to, amountEth) {
// 1. If we already recorded a hash, check whether it landed.
const prior = store.get(opId);
if (prior?.hash) {
const receipt = await provider.getTransactionReceipt(prior.hash);
if (receipt) {
console.log('Already applied, not re-sending:', prior.hash);
return receipt;
}
console.log('Prior hash not mined yet, re-broadcasting same payload');
return provider.broadcastTransaction(prior.raw);
}
// 2. Build and sign once, persist BEFORE sending.
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const tx = await wallet.populateTransaction({ to, value: parseEther(amountEth), nonce });
const raw = await wallet.signTransaction(tx);
store.set(opId, { raw, hash: null });
// 3. Send. A timeout here does NOT mean failure.
try {
const sent = await provider.broadcastTransaction(raw);
store.set(opId, { raw, hash: sent.hash });
console.log('Sent:', sent.hash);
return sent;
} catch (err) {
console.log('Ambiguous outcome (timeout/reset):', err.message);
// 4. Do NOT blindly re-send. Check chain state first.
const hash = (await import('ethers')).keccak256(raw);
const receipt = await provider.getTransactionReceipt(hash);
if (receipt) {
store.set(opId, { raw, hash });
console.log('Found on chain despite error:', hash);
return receipt;
}
console.log('Not found; safe to retry with same payload');
return provider.broadcastTransaction(raw);
}
}
await sendOnce('op-2026-09-13-001', '0x000000000000000000000000000000000000dEaD', '0.001');Una prueba reproducible en testnet: reintento ingenuo vs reintento protegido
Puedes demostrarte el riesgo a ti mismo en una testnet. Ejecuta dos variantes contra la misma cuenta de prueba financiada y compara los balances y los recuentos de transacciones resultantes. Esta es una medición que realizas tú, no un benchmark que afirmamos; registra tus propios números en la tabla de abajo.
Variante A (ingenua): envía una transferencia, fuerza un timeout del lado del cliente abortando la solicitud tras un breve retardo y luego reenvía inmediatamente la misma transferencia lógica con un nuevo nonce. Variante B (protegida): envía la misma transferencia, fuerza el mismo timeout y luego comprueba eth_getTransactionReceipt por el hash original antes de decidir si reenviar.
Compara el delta de balance del destinatario y el recuento de transacciones del remitente. La variante A puede mostrar dos aplicaciones; la variante B debería mostrar una. Ejecuta cada variante varias veces y anota la varianza, porque el timing determina si la primera transacción se confirmó antes del aborto.
- Columnas de la tabla de resultados: Variante | Delta destinatario | Recuento tx remitente | Duplicado observado (S/N) | Notas.
- Ejecuta cada variante al menos 5 veces; el timing cambia el resultado.
- Usa una cuenta de prueba nueva por ejecución para evitar arrastrar nonces.
- Registra el endpoint RPC y la versión del cliente junto a los resultados.
Wrappers HTTP con Idempotency-Key y qué hacer sin una
Algunas APIs con frontend HTTP soportan una cabecera Idempotency-Key, donde el servidor almacena la clave y devuelve la respuesta original ante una repetición. Si tu endpoint documenta esto, úsalo: genera una clave determinista por operación de negocio, envíala en cada intento y deja que el servidor deduplique. Este es un comportamiento documentado de la plataforma, no una garantía de JSON-RPC.
Cuando el endpoint no lo soporta, debes implementar la deduplicación en el lado del cliente usando las primitivas nativas de la cadena anteriores. No hay atajo: si ni el servidor ni la cadena ofrecen una clave de deduplicación, la única resolución segura es leer el estado y decidir. Las páginas Servicio API y Precios de RPC describen la superficie del servicio; consulta la documentación específica del endpoint para saber si se honra una cabecera de idempotencia.
Un patrón útil es un wrapper fino que siempre adjunta tu id de operación como metadato y lo registra, incluso cuando el servidor lo ignora. Eso te da una pista de auditoría para reconciliar duplicados a posteriori.
- Usa Idempotency-Key solo donde el endpoint documente soporte.
- Sin soporte del servidor, deduplica en el cliente mediante nonce/hash/firma.
- Registra tu id de operación aunque el servidor lo ignore.
- Reconcilia resultados ambiguos leyendo el estado de la cadena.
Interacción con el hedging de solicitudes y las solicitudes por lotes
El hedging de solicitudes envía la misma llamada a múltiples endpoints y toma la primera respuesta. Es seguro para lecturas y peligroso para escrituras: hacer hedging de una escritura puede provocar que la misma transacción se difunda por dos rutas y, aunque el nonce normalmente impide una doble aplicación en EVM, todavía puede producir errores confusos y comisiones desperdiciadas. La guía Hedging de solicitudes RPC y latencia de cola advierte explícitamente de hacer hedging solo con métodos de lectura; trátalo como una regla estricta.
Las solicitudes por lotes agravan el problema. Un lote es una única solicitud HTTP que contiene múltiples llamadas JSON-RPC, y un fallo parcial significa que algunas sub-solicitudes pueden haberse aplicado mientras que otras no. No puedes asumir que el lote es atómico. Al reintentar, razona sobre cada sub-solicitud individualmente usando su propio id y su propia identidad nativa de la cadena.
El patrón seguro es mantener las escrituras fuera de los lotes por completo, o agrupar solo lecturas y emitir escrituras de una en una con manejo explícito de verificar-antes-de-reenviar.
- Nunca hagas hedging de una llamada que modifica el estado.
- Los lotes no son atómicos; la aplicación parcial es posible.
- Razona por sub-solicitud al reintentar, no por lote.
- Prefiere escrituras individuales con deduplicación explícita antes que escrituras por lotes.
Limitaciones: rechazado vs aplicado-pero-respuesta-perdida
Hay una frontera fundamental que el cliente no puede cruzar por sí solo. Cuando una escritura devuelve un error, no puedes saber desde el cliente si el nodo la rechazó (por ejemplo, fondos insuficientes, nonce incorrecto o un revert) o si la aplicó y perdió la respuesta. Estos dos casos requieren acciones opuestas: reenviar en el primero, no reenviar en el segundo.
La única resolución fiable es leer el estado de la cadena: comprueba el hash de transacción, el nonce de la cuenta y el recibo. Si el nonce avanzó y el recibo existe, se aplicó. Si el nonce no cambió y no existe recibo tras una espera razonable, probablemente no se aplicó. Ten en cuenta que 'probablemente' hace un trabajo real aquí; hay una ventana en la que una transacción está en el mempool pero aún no minada, y durante esa ventana el resultado es genuinamente desconocido.
Diseña explícitamente para esta ventana. Persiste el payload firmado para poder volver a difundirlo sin cambios y haz polling en lugar de re-firmar. Re-firmar con un nuevo nonce durante la ventana desconocida es lo que convierte un resultado ambiguo en un duplicado.
- El cliente por sí solo no puede distinguir entre rechazado y aplicado-pero-perdido.
- Resuelve leyendo nonce, hash y recibo.
- La ventana del mempool es genuinamente desconocida; haz polling, no re-firmes.
- Persiste el payload firmado para permitir un reenvío seguro.
Solución de problemas de escrituras duplicadas y próximos pasos
Si ya estás viendo duplicados, empieza comprobando si tu ruta de reintento re-firma con un nuevo nonce. Esa es la causa más común. Después, confirma si tu cliente persiste el hash de transacción antes de que el envío retorne; si solo lo registra al tener éxito, un timeout pierde el hash y obliga a adivinar. Por último, comprueba si alguna escritura está siendo objeto de hedging o de lotes, ya que ambas amplían la superficie de fallo.
Como próximos pasos, integra el patrón at-most-once en tu librería cliente, añade un almacén de operaciones duradero y añade un trabajo de reconciliación que busque ids de operación sin hash registrado y los resuelva leyendo el estado de la cadena. Luego revisa tus rutas de lectura contra el centro de aprendizaje de OnFinality para obtener orientación relacionada sobre timeouts y nonces, y confirma el comportamiento de tu endpoint contra la guía de endpoints RPC (RPC Assistant).
El objetivo no es eliminar los reintentos; los reintentos son necesarios. El objetivo es hacer que cada reintento sea seguro asegurando que o bien vuelve a difundir un payload idéntico o bien comprueba primero el estado de la cadena. Eso es lo que convierte una integración frágil en una at-most-once.
- Comprueba si se re-firma con un nuevo nonce al reintentar.
- Verifica que el hash se persiste antes de que el envío retorne.
- Elimina las escrituras de las rutas con hedging y por lotes.
- Añade un trabajo de reconciliación que resuelva resultados desconocidos leyendo el estado de la cadena.