Endpoints idempotentes en Go: claves, tablas de deduplicación y reintentos
Diseña endpoints idempotentes en Go usando claves de idempotencia, tablas de deduplicación y handlers seguros frente a reintentos para pagos, importaciones y webhooks.

Por qué los reintentos crean duplicados (y por qué la idempotencia importa)
Los reintentos ocurren incluso cuando no hay nada “mal”. Un cliente agota el tiempo de espera mientras el servidor aún está procesando. Una conexión móvil cae y la app lo vuelve a intentar. Un job runner recibe un 502 y reenvía automáticamente la misma petición. Con entrega al menos una vez (común en colas y webhooks), los duplicados son normales.
Por eso la idempotencia importa: las peticiones repetidas deberían llevar al mismo resultado final que una sola petición.
Algunos términos son fáciles de confundir:
- Seguro (Safe): invocarlo no cambia el estado (como una lectura).
- Idempotente: llamarlo varias veces tiene el mismo efecto que llamarlo una vez.
- Al menos una vez (At-least-once): el emisor reintenta hasta que “pega”, así que el receptor debe manejar duplicados.
Sin idempotencia, los reintentos pueden causar daños reales. Un endpoint de pagos puede cobrar dos veces si el primer cobro se completó pero la respuesta no llegó al cliente. Un endpoint de importación puede crear filas duplicadas cuando un worker reintenta tras un timeout. Un manejador de webhooks puede procesar el mismo evento dos veces y enviar dos correos.
El punto clave: la idempotencia es un contrato de API, no un detalle privado de implementación. Los clientes necesitan saber qué pueden reintentar, qué clave enviar y qué respuesta esperar cuando se detecta un duplicado. Si cambias el comportamiento silenciosamente, rompes la lógica de reintentos y creas nuevos modos de fallo.
La idempotencia tampoco sustituye al monitoreo y la conciliación. Mide tasas de duplicados, registra decisiones de “replay” y compara periódicamente sistemas externos (como un proveedor de pagos) con tu base de datos.
Elige el alcance de idempotencia y las reglas para cada endpoint
Antes de añadir tablas o middleware, decide qué significa “misma petición” y qué promete hacer tu servidor cuando un cliente reintente.
La mayoría de problemas aparecen en POST porque suele crear algo o disparar un efecto lateral (cobrar, enviar un mensaje, iniciar una importación). PATCH también puede necesitar idempotencia si dispara efectos, no solo una actualización sencilla de un campo. GET no debería cambiar estado.
Define el alcance: dónde es única una clave
Elige un scope que coincida con tus reglas de negocio. Demasiado amplio bloquea trabajo válido. Demasiado estrecho permite duplicados.
Scopes comunes:
- Por endpoint + cliente
- Por endpoint + objeto externo (por ejemplo, invoice_id u order_id)
- Por endpoint + tenant (en sistemas multi-tenant)
- Por endpoint + método de pago + importe (solo si tus reglas de producto lo permiten)
Ejemplo: para un endpoint “Crear pago”, haz la clave única por cliente. Para “Ingresar evento de webhook”, acótalo al ID de evento del proveedor (unicidad global provista por el proveedor).
Decide qué repetir ante duplicados
Cuando llega un duplicado, devuelve el mismo resultado que el primer intento exitoso. En la práctica, eso significa reproducir el mismo código de estado HTTP y el mismo cuerpo de respuesta (o al menos el mismo ID de recurso y estado).
Los clientes dependen de esto. Si el primer intento tuvo éxito pero la red se cayó, el reintento no debería crear un segundo cobro o un segundo job de importación.
Elige una ventana de retención
Las claves deben expirar. Consérvalas el tiempo suficiente para cubrir reintentos realistas y jobs retrasados.
- Pagos: 24 a 72 horas es común.
- Importaciones: una semana puede ser razonable si los usuarios reintentan más tarde.
- Webhooks: empata con la política de reintentos del proveedor.
Define “misma petición”: clave explícita vs hash del cuerpo
Una clave explícita de idempotencia (header o campo) suele ser la regla más limpia.
Un hash del cuerpo puede ayudar como respaldo, pero se rompe fácilmente con cambios inofensivos (orden de campos, espacios, timestamps). Si usas hashing, normaliza la entrada y sé estricto sobre qué campos incluyes.
Claves de idempotencia: cómo funcionan en la práctica
Una clave de idempotencia es un contrato simple entre cliente y servidor: “Si ves esta clave otra vez, trátalo como la misma petición”. Es una de las herramientas más prácticas para APIs seguras frente a reintentos.
La clave puede venir de cualquiera de las dos partes, pero para la mayoría de APIs debería generarla el cliente. El cliente sabe cuándo está volviendo a intentar la misma acción, así que puede reusar la misma clave en los intentos. Las claves generadas por el servidor ayudan cuando primero creas un recurso “borrador” (como un job de importación) y luego permites que los clientes reintenten referenciando ese ID de job, pero no ayudan en la primera petición.
Usa una cadena aleatoria e impredecible. Apunta a al menos 128 bits de entropía (por ejemplo, 32 caracteres hex o un UUID). No construyas claves a partir de timestamps o IDs de usuario.
En el servidor, guarda la clave con suficiente contexto para detectar mal uso y reproducir el resultado original:
- Quién realizó la llamada (account o user ID)
- Qué endpoint u operación aplica
- Un hash de los campos importantes de la petición
- Estado actual (en progreso, exitoso, fallido)
- La respuesta para reproducir (código de estado y cuerpo)
Una clave debe estar acotada, típicamente por usuario (o token de API) más endpoint. Si la misma clave se reusa con una carga distinta, recházala con un error claro. Eso evita colisiones accidentales donde un cliente con un bug envía un nuevo importe usando una clave antigua.
En el replay, devuelve el mismo resultado que el primer intento exitoso. Eso significa el mismo código de estado HTTP y el mismo cuerpo de respuesta, no una lectura fresca que pueda haber cambiado.
Tablas de dedup en PostgreSQL: un patrón simple y fiable
Una tabla de deduplicación dedicada es una de las formas más sencillas de implementar idempotencia. La primera petición crea una fila para la clave de idempotencia. Cada reintento lee esa misma fila y devuelve el resultado almacenado.
Qué almacenar
Mantén la tabla pequeña y enfocada. Una estructura común:
key: la clave de idempotencia (text)owner: a quién pertenece la clave (user_id, account_id o ID del cliente API)request_hash: un hash de los campos importantes de la peticiónresponse: la carga final de la respuesta (a menudo JSON) o un puntero a un resultado almacenadocreated_at: cuándo se vio la clave por primera vez
La restricción única es el núcleo del patrón. Aplica unicidad en (owner, key) para que un cliente no cree duplicados, y dos clientes distintos no colisionen.
También guarda un request_hash para detectar mal uso. Si llega un reintento con la misma clave pero distinto hash, devuelve un error en vez de mezclar dos operaciones distintas.
Retención e indexación
Las filas de dedup no deberían vivir para siempre. Consérvalas lo suficiente para cubrir ventanas reales de reintento y luego límpialas.
Para velocidad bajo carga:
- Índice único en
(owner, key)para inserciones o búsquedas rápidas - Índice opcional en
created_atpara facilitar la limpieza
Si la respuesta es grande, guarda un puntero (por ejemplo, un result ID) y mantén la carga completa en otro lugar. Eso reduce el crecimiento excesivo de la tabla mientras mantienes un comportamiento de reintento consistente.
Paso a paso: flujo de un handler seguro frente a reintentos en Go
Un handler seguro frente a reintentos necesita dos cosas: una forma estable de identificar “la misma petición otra vez” y un lugar duradero para almacenar el primer resultado y poder reproducirlo.
Un flujo práctico para pagos, importaciones e ingestión de webhooks:
-
Valida la petición y deriva tres valores: una clave de idempotencia (desde un header o campo del cliente), un owner (tenant o user ID) y un request hash (hash de los campos importantes).
-
Inicia una transacción de base de datos e intenta crear un registro de dedup. Hazlo único en
(owner, key). Guardarequest_hash, estado (started, completed) y placeholders para la respuesta. -
Si la inserción entra en conflicto, carga la fila existente. Si está completada, devuelve la respuesta guardada. Si está en progreso, o espera brevemente (polling simple) o devuelve 409/202 para que el cliente reintente más tarde.
-
Solo cuando “posees” la fila de dedup, ejecuta la lógica de negocio una vez. Escribe los efectos secundarios dentro de la misma transacción cuando sea posible. Persiste el resultado y la respuesta HTTP (código y cuerpo).
-
Haz commit y registra la operación con la clave de idempotencia y el owner para que soporte pueda rastrear duplicados.
Un patrón mínimo de tabla:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
Ejemplo: un endpoint “Crear payout” agota el tiempo tras cobrar. El cliente reintenta con la misma clave. Tu handler choca con la inserción, ve un registro completado y devuelve el ID del payout original sin volver a cobrar.
Pagos: cobrar exactamente una vez, incluso con timeouts
En pagos la idempotencia deja de ser opcional. Las redes fallan, las apps móviles reintentan y las pasarelas a veces agotan el tiempo después de ya haber creado el cargo.
Una regla práctica: la clave de idempotencia protege la creación del cargo, y el ID del proveedor (charge/intent) se convierte en la fuente de la verdad tras eso. Una vez guardes un provider ID, no crees un nuevo cargo para la misma petición.
Un patrón que maneja reintentos e incertidumbre del gateway:
- Lee y valida la clave de idempotencia.
- En una transacción, crea o recupera una fila de pago indexada por
(merchant_id, idempotency_key). Si ya tieneprovider_id, devuelve el resultado guardado. - Si no hay
provider_id, llama al gateway para crear un PaymentIntent/Charge. - Si el gateway responde éxito, persiste
provider_idy marca el pago como “succeeded” (o “requires_action”). - Si el gateway agota el tiempo o devuelve un resultado desconocido, guarda estado “pending” y devuelve una respuesta consistente que indique al cliente que es seguro reintentar.
El detalle clave es cómo tratas los timeouts: no asumas fallo. Marca el pago como pendiente y confirma consultando al gateway después (o mediante un webhook) usando el provider ID cuando lo tengas.
Las respuestas de error deben ser predecibles. Los clientes construyen lógica de reintento alrededor de lo que devuelves, así que mantén códigos y formas de error estables.
Importaciones y endpoints por lotes: dedup sin perder progreso
Las importaciones son donde los duplicados hacen más daño. Un usuario sube un CSV, tu servidor agota el tiempo al 95% y vuelve a intentar. Sin un plan, o creas filas duplicadas o le obligas a empezar de nuevo.
Para trabajo por lotes piensa en dos capas: el job de importación y los ítems dentro de él. La idempotencia a nivel de job impide que la misma petición cree múltiples jobs. La idempotencia a nivel de ítem evita que la misma fila se aplique dos veces.
Un patrón a nivel de job es requerir una clave de idempotencia por petición de importación (o derivarla de un request hash estable más el user ID). Guárdala con un registro import_job y devuelve el mismo job ID en reintentos. El handler debe poder decir “he visto este job, aquí está su estado actual” en lugar de “empieza de nuevo”.
Para dedup a nivel de ítem, fíate de una clave natural que ya exista en los datos. Por ejemplo, cada fila puede incluir un external_id del sistema origen, o una combinación estable como (account_id, email). Hazla única con una constraint en PostgreSQL y usa upsert para que los reintentos no creen duplicados.
Antes de lanzar, decide qué hace un replay cuando una fila ya existe. Sé explícito: omitir, actualizar campos específicos o fallar. Evita “fusionar” a menos que tengas reglas muy claras.
El éxito parcial es normal. En lugar de devolver un único “ok” o “failed”, guarda resultados por fila ligados al job: número de fila, clave natural, estado (created, updated, skipped, error) y mensaje de error. En un reintento, puedes re-ejecutar con seguridad mientras mantienes los mismos resultados para las filas ya terminadas.
Para hacer las importaciones reiniciables, añade checkpoints. Procesa en páginas (por ejemplo, 500 filas a la vez), guarda el cursor del último procesado (índice de fila o cursor del origen) y actualízalo después de confirmar cada página. Si el proceso falla, el siguiente intento reanuda desde el último checkpoint.
Ingestión de webhooks: dedup, valida y procesa con seguridad
Los emisores de webhooks reintentan. También envían eventos fuera de orden. Si tu handler actualiza estado en cada entrega, acabarás creando registros duplicados, enviando correos duplicados o cobrando dos veces.
Empieza eligiendo la mejor clave de dedup. Si el proveedor te da un ID de evento único, úsalo. Sóplate a un hash del payload solo cuando no haya ID de evento.
La seguridad va primero: verifica la firma antes de aceptar nada. Si la firma falla, rechaza la petición y no escribas un registro de dedup. De lo contrario, un atacante podría “reservar” un ID de evento y bloquear eventos reales después.
Un flujo seguro ante reintentos:
- Verifica la firma y la forma básica (headers requeridos, event ID).
- Inserta el event ID en una tabla de dedup con restricción única.
- Si la inserción falla por duplicado, devuelve 200 inmediatamente.
- Guarda el payload crudo (y headers) cuando sea útil para auditoría y debugging.
- Encola el procesamiento y devuelve 200 rápidamente.
Responder rápido importa porque muchos proveedores tienen timeouts cortos. Haz en la petición el trabajo mínimo fiable: verificar, dedup y persistir. Luego procesa de forma asíncrona (worker, cola, job en background). Si no puedes hacer async, mantén el procesamiento idempotente claveando los efectos secundarios internos al mismo event ID.
La entrega fuera de orden es normal. No asumas que “created” llega antes que “updated”. Prefiere upserts por external object ID y registra la última marca temporal o versión procesada.
Almacenar payloads crudos ayuda cuando un cliente dice “nunca recibimos la actualización”. Puedes re-ejecutar el procesamiento desde el cuerpo almacenado después de arreglar un bug, sin pedirle al proveedor que reenvíe.
Concurrencia: mantener la corrección ante peticiones paralelas
Los reintentos se complican cuando dos peticiones con la misma clave llegan al mismo tiempo. Si ambos handlers ejecutan el paso “hacer trabajo” antes de que cualquiera guarde el resultado, puedes seguir cobrando doble, importando doble o encolando doble.
El punto de coordinación más simple es la transacción de base de datos. Haz el primer paso "reclamar la clave" y deja que la base de datos decida quién gana. Opciones comunes:
- Inserción única en una tabla de dedup (la base de datos decide un ganador)
SELECT ... FOR UPDATEtras crear (o encontrar) la fila de dedup- Locks secundarios de transacción (advisory locks) indexados por un hash de la clave
- Constraints únicas en el registro de negocio como último recurso
Para trabajo de larga duración, evita mantener bloqueos de fila mientras llamas a sistemas externos o ejecutas importaciones que tarden minutos. En su lugar, guarda una pequeña máquina de estados en la fila de dedup para que otras peticiones salgan rápido.
Un conjunto práctico de estados:
in_progressconstarted_atcompletedcon respuesta cacheadafailedcon un código de error (opcional, según tu política de reintentos)expires_at(para limpieza)
Ejemplo: dos instancias de app reciben la misma petición de pago. La instancia A inserta la clave y marca in_progress, luego llama al proveedor. La instancia B choca con el insert, lee la fila de dedup, ve in_progress y devuelve una respuesta rápida de “aún procesando” (o espera brevemente y reconsulta). Cuando A termina, actualiza la fila a completed y guarda el cuerpo de la respuesta para que reintentos posteriores obtengan exactamente la misma salida.
Errores comunes que rompen la idempotencia
La mayoría de los bugs de idempotencia no vienen de bloqueos exóticos. Son elecciones “casi correctas” que fallan bajo reintentos, timeouts o dos usuarios haciendo acciones similares.
Una trampa común es tratar la clave de idempotencia como globalmente única. Si no la acotas (por usuario, cuenta o endpoint), dos clientes distintos pueden colisionar y uno recibirá el resultado del otro.
Otro problema es aceptar la misma clave con un cuerpo distinto. Si la primera llamada fue por $10 y el replay es por $100, no deberías devolver silenciosamente el primer resultado. Guarda un request hash (o campos clave), compara en el replay y devuelve un error de conflicto claro.
Los clientes también se confunden cuando los replays devuelven una forma de respuesta o un código distinto. Si la primera llamada devolvió 201 con un JSON, el replay debería devolver el mismo cuerpo y el mismo código. Cambiar el comportamiento de replay obliga a los clientes a adivinar.
Errores que frecuentemente causan duplicados:
- Confiar solo en un mapa en memoria o cache y perder estado de dedup tras un reinicio.
- Usar una clave sin scope (colisiones entre usuarios o endpoints).
- No validar diferencias de payload para la misma clave.
- Hacer el efecto lateral primero (cobrar, insertar, publicar) y escribir el registro de dedup después.
- Devolver un ID generado nuevo en cada reintento en lugar de reproducir el resultado original.
Una cache puede acelerar lecturas, pero la fuente de la verdad debe ser duradera (normalmente PostgreSQL). Si no, reintentos tras un deploy pueden crear duplicados.
También planifica la limpieza. Si guardas cada clave para siempre, las tablas crecen y los índices se vuelven lentos. Define una ventana de retención basada en comportamiento real de reintentos, borra filas antiguas y mantén el índice único pequeño.
Lista de verificación rápida y siguientes pasos
Trata la idempotencia como parte de tu contrato de API. Cada endpoint que pueda ser reintentado por un cliente, una cola o una pasarela necesita una regla clara de qué significa “misma petición” y cómo luce el “mismo resultado”.
Lista de verificación antes de lanzar:
- Para cada endpoint reintentable, ¿está definido el scope de idempotencia (por usuario, cuenta, orden, evento externo) y documentado?
- ¿La deduplicación está aplicada por la base de datos (constraint única en la clave y scope) y no solo verificada en código?
- En replay, ¿devuelves el mismo código y cuerpo (o un subconjunto documentado y estable) y no un objeto fresco o una nueva marca temporal?
- Para pagos, ¿manejáis resultados desconocidos con seguridad (timeout tras enviar, gateway dice “processing”) sin cobrar dos veces?
- ¿Logs y métricas muestran claramente cuándo una petición fue vista por primera vez vs. cuando fue reproducida?
Si algún ítem es un “quizás”, arréglalo ahora. La mayoría de fallos aparecen bajo estrés: reintentos paralelos, redes lentas y outages parciales.
Si construyes herramientas internas o apps de cliente sobre AppMaster (appmaster.io), conviene diseñar las claves de idempotencia y la tabla de dedup en PostgreSQL desde el principio. Así, aunque la plataforma regenere código backend en Go cuando cambien los requisitos, tu comportamiento frente a reintentos se mantiene consistente.
FAQ
Los reintentos son normales: redes y clientes fallan en situaciones cotidianas. Una petición puede haberse procesado correctamente en el servidor pero la respuesta no llega al cliente; cuando el cliente reintenta, terminas realizando el mismo trabajo dos veces a menos que el servidor reconozca y reprograme (replay) el resultado original.
Envía la misma clave en cada reintento de la misma acción. Générala en el cliente como una cadena aleatoria e impredecible (por ejemplo, un UUID) y no la reutilices para otra acción distinta.
Define el alcance según tu regla de negocio: normalmente por endpoint más una identidad del llamante (usuario, cuenta, tenant o token de API). Esto evita que dos clientes distintos colisionen en la misma clave y reciban resultados ajenos.
Devuelve el mismo resultado que la primera solicitud exitosa. En la práctica, reenvía el mismo código de estado HTTP y el mismo cuerpo de respuesta —o al menos el mismo ID de recurso y estado— para que el cliente pueda reintentar sin causar un segundo efecto lateral.
Recházala con un error claro de tipo conflicto en lugar de suponer o adivinar. Guarda y compara un hash de los campos importantes de la petición; si la clave coincide pero la carga útil es diferente, falla rápido para evitar mezclar dos operaciones distintas bajo una misma clave.
Conserva las claves el tiempo suficiente para cubrir reintentos realistas y luego elimínalas. Un valor común es 24–72 horas para pagos, una semana para importaciones, y para webhooks ajustarlo a la política de reintentos del emisor para que reintentos tardíos sigan siendo deduplicados correctamente.
Una tabla dedicada de deduplicación funciona bien: la base de datos puede aplicar una restricción única y sobrevivir a reinicios. Guarda el scope del owner, la clave, un hash de la petición, un estado y la respuesta a reproducir; haz (owner, key) único para que sólo una petición “gane”.
Reclama la clave dentro de una transacción de base de datos antes de hacer el efecto lateral. Si otra petición llega en paralelo, debe chocar con la restricción única, ver in_progress o completed y devolver una respuesta de espera/replay en lugar de ejecutar la lógica dos veces.
Trata los timeouts como “resultado desconocido”, no como fallo. Registra un estado pendiente y, si dispones de un provider ID, úsalo como fuente de la verdad para que los reintentos devuelvan el mismo resultado de pago en vez de crear un nuevo cargo.
Haz dedup en dos niveles: a nivel de job y a nivel de ítem. Haz que los reintentos devuelvan el mismo ID de job, y aplica una clave natural a las filas (por ejemplo, un external_id o (account_id, email)) con restricciones únicas o upserts para que reprocesar no cree duplicados.


