Idempotencia y reintentos
Cómo reintentar un evento sin duplicar recompensas, usando operationId
Mandar un evento (POST /gamification/triggers) no es una operación segura de reintentar a ciegas: si la primera llamada llegó y completó una misión, y tu sistema la reintenta por un timeout de red, el usuario avanza dos veces.
Para eso existe operationId.
Cómo se usa
Agregá un operationId único por evento de negocio en el body:
{
"programmaticCode": "PAGO_SERVICIO",
"accountId": "user-4821",
"idType": "EXTERNAL",
"metadata": { "amount": 1500 },
"operationId": "7f3d9a4c-2e5b-4a6f-9c1d-8e2b3a4d5e6f"
}| Formato | String de 1 a 64 caracteres. Recomendado: un UUID. |
| Alcance | Por cliente. Dos clientes distintos pueden usar el mismo operationId sin pisarse. |
| Opcional | Si no lo mandás, no hay protección: cada request se procesa como un evento nuevo. |
El operationId tiene que venir de tu operación de negocio (el id de la transacción, del pago, del movimiento), no generarse en el momento de la request. Si lo generás por request, cada reintento lleva uno distinto y no protege de nada.
Qué pasa con un duplicado
| Estado del original | Respuesta al duplicado |
|---|---|
| Ya procesado | 200 con el mismo body que devolvió la primera llamada. No se vuelve a procesar: no se otorgan recompensas de nuevo ni avanza el progreso. |
| Todavía en curso | 409 — "A trigger notification with this operationId is already being processed". Reintentá más tarde con el mismo operationId. |
| Falló | 200 con { "rewards": [], "progressedMissions": [] } — ver la advertencia de abajo. |
Un operationId se quema aunque el procesamiento falle. Si la primera llamada entró pero explotó de nuestro lado, el reintento con el mismo operationId no reprocesa el evento: devuelve 200 con listas vacías. Un 200 con rewards: [] y progressedMissions: [] es ambiguo — puede ser un evento que legítimamente no movió nada, o un original fallido.
Si el evento es crítico y recibiste un 5xx en la primera llamada, reintentá con un operationId nuevo en lugar de repetir el original.
Qué reintentar y qué no
| Respuesta | ¿Reintentar? |
|---|---|
2xx | No. Ya se procesó. |
400 | No. El body es inválido; reintentar da lo mismo. |
401 / 403 | No. Revisá la API Key o los permisos. Ver Errores. |
404 | No. El programmaticCode no existe para tu cliente, o la cuenta no existe con idType=INTERNAL. |
409 de idempotencia | Sí, con el mismo operationId y backoff. |
5xx o timeout | Sí, con backoff exponencial. Si mandaste operationId, leé la advertencia de arriba. |
async function sendEvent(body, attempt = 0) {
const res = await fetch(`${BASE}/gamification/triggers`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const err = await res.json();
// Original todavía en vuelo: mismo operationId, más tarde.
const retryable = res.status >= 500 || res.status === 409;
if (retryable && attempt < 4) {
await sleep(2 ** attempt * 500);
return sendEvent(body, attempt + 1);
}
throw new ApiError(err.code, err.traceId);
}El resto de la API
operationId aplica hoy a los dos endpoints de envío de eventos:
POST /gamification/triggers(sincrónico)POST /gamification/triggers/async
Los demás endpoints no tienen clave de idempotencia. Para las escrituras que importan:
POST /accounts— si la cuenta ya existe para eseexternalId, usáPOST /accounts/resolve, que devuelve la existente o la crea. Es seguro de repetir.- Canjes (
/marketplace/publications/redeem/:id,/loyalty/products/:id/redeem) — no son idempotentes. Un reintento tras un timeout puede generar un segundo canje. Antes de reintentar, consultáGET /redemptions/:accountIdpara ver si el primero entró.
