Retries reporting the same failure twice? Dedupe by fingerprint
Why retry loops inflate error counts on flaky networks, and the three-layer dedupe that fixes it: a stable fingerprint, a report-once latch, and read-side reconciliation.
A retry loop that reports on every attempt doesn’t produce more information, it produces more rows. The dedupe pattern has three layers and only one of them lives on the server: give each logical failure a fingerprint that ignores the attempt number, report once per logical failure instead of once per attempt, and reconcile the counts you read back. Infrai’s errors API groups strictly by the fingerprint you send, so layer one is a single design decision.
That decision is yours here in a way it isn’t elsewhere. Sentry and Rollbar derive a grouping key from parsed stack frames, so a retried exception usually lands in the right issue by accident. Infrai hashes the fingerprint string you supply — nothing else — which is more control and more rope.
Two different duplicates, two different fixes
Flaky client networks generate two failure shapes that look identical in a dashboard and aren’t.
The first is a genuine retry: three attempts against a payment gateway, three real timeouts, one logical failure from the user’s point of view. The second is an ack loss — your capture POST reached us, the response never made it back over the client’s patchy uplink, and your HTTP client retried a request that had already been recorded. The first is solved by a latch in your own code. The second is only solvable if something on the write path is idempotent, and this is the honest part: POST /v1/errors/capture accepts an idempotency_key in its schema, but in our testing (2026-07-26) sending the same key twice still produced two distinct event_id values inside one group. Treat it as metadata, not as duplicate suppression.
The fingerprint that survives a retry
A good fingerprint names the code path, not the incident. Attempt numbers, order ids, timestamps, latency values and generated request ids all have to stay out of it — they belong in the message, where they’re searchable but not structural.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "TimeoutError: payments.charge timed out after 8000ms\n at chargeOrder (/app/src/payments.js:88:11)",
"exception": "TimeoutError",
"fingerprint": "payments:chargeOrder:TimeoutError",
"environment": "production",
"release": "2026.07.19"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_MmoX5hx13fY6WHdtaJLxLFu8",
"fingerprint": "8bdf628565e1c20babd8d62110ec9ea4677826742fad00fcb8ea92753543a471",
"error_group_id": "errgrp_xBaxKvlNOuc3s0fAxXSgg2R2",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_MmoX5hx13fY6WHdtaJLxLFu8"
}
}
The string you send comes back as a 64-character hash, and is_new_group tells you whether that key existed already. On the next occurrence you get the same error_group_id and is_new_group: false. That flag is worth wiring into your alerting: a regression is a new group, not a busy one.
The latch: one report per logical failure
Fingerprinting keeps your issue list tidy. It does nothing for your event count, because three attempts still cost three writes and three rows in the group. The counter you actually want to trust — “how many orders failed” — needs the reporting call to sit outside the retry, not inside it.
// report-once.mjs — Node 22 ESM. Reports a logical failure exactly once,
// no matter how many transport attempts it took.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function report({ error, scope, release }) {
const res = await fetch(`${API}/v1/errors/capture`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
message: (error.stack ?? error.message).slice(0, 8000),
exception: error.name,
fingerprint: `${scope}:${error.name}`,
environment: process.env.NODE_ENV ?? "development",
release,
}),
});
if (!res.ok) {
console.error("[errors] capture failed", res.status, await res.text());
return null;
}
const { data } = await res.json();
return data.error_group_id;
}
export async function withRetry(scope, fn, { attempts = 3, baseMs = 250 } = {}) {
let last;
for (let i = 1; i <= attempts; i++) {
try {
return await fn(i);
} catch (err) {
last = err instanceof Error ? err : new Error(String(err));
if (i < attempts) await new Promise((r) => setTimeout(r, baseMs * 2 ** (i - 1)));
}
}
// One capture for the whole exhausted loop — the attempt count is data,
// not a separate incident.
last.message = `${last.message} (after ${attempts} attempts)`;
await report({ error: last, scope, release: process.env.APP_RELEASE ?? "dev" });
throw last;
}
Three attempts, one event. If you want the intermediate attempts visible without paying for them, send them to POST /v1/errors/message, which is free and takes a text field plus optional level — useful for a level: "warning" breadcrumb per retry while the billable capture stays once per logical failure.
Reconciling what you already over-counted
Reads are free, so the repair is arithmetic rather than a migration. Pull the group and look at count against the number of distinct failures you believe happened.
curl -sS "https://api.infrai.cc/v1/errors/group_detail/errgrp_xBaxKvlNOuc3s0fAxXSgg2R2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"error_group_id": "errgrp_xBaxKvlNOuc3s0fAxXSgg2R2",
"title": "TimeoutError: payments.charge timed out after 8000ms",
"first_seen_at": "2026-07-26T01:23:44.215686Z",
"last_seen_at": "2026-07-26T01:23:57.427535Z",
"count": 3,
"user_count": 0,
"level": "error",
"is_resolved": false,
"environments": ["production"],
"release_distribution": { "2026.07.19": 3 }
}
}
If count is a clean multiple of your retry budget, the latch is missing. A ratio near 1.0 means you’re already reporting once. Here’s the sweep across a whole environment, in Python 3:
import os, collections, requests
KEY = os.environ["INFRAI_API_KEY"]
r = requests.get(
"https://api.infrai.cc/v1/errors/list",
headers={"Authorization": f"Bearer {KEY}"},
params={"environment": "production", "limit": 100},
timeout=20,
)
r.raise_for_status()
items = r.json()["data"]["items"]
per_group = collections.Counter(i["error_group_id"] for i in items)
for group_id, events in per_group.most_common(10):
title = next(i["title"] for i in items if i["error_group_id"] == group_id)
print(f"{events:4d} {group_id} {title[:60]}")
Note the filter: /v1/errors/list honours environment, level and release, while GET /v1/errors/groups only filters on status and GET /v1/errors/search needs a non-empty q and ignores everything else. That asymmetry has bitten us; plan the query around it.
Which layer to reach for
| Layer | Removes | Costs | Fails when |
|---|---|---|---|
| Stable fingerprint | separate rows per attempt | nothing | you embed an order id or attempt number |
| Report-once latch | duplicate events per failure | a few lines around the retry | the process dies mid-loop |
| Idempotency on the write | ack-loss duplicates | — | not available on capture today |
| Read-side reconcile | nothing, but tells you the truth | free reads | you never look |
Once a group is genuinely fixed, close it — the route takes the group id in the path:
curl -sS -X POST "https://api.infrai.cc/v1/errors/resolve/{error_group_id}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json"
It returns the whole updated group, and the group auto-unresolves if the fingerprint reappears — which is the signal you want after a network incident.
What this costs, and where it doesn’t fit
Captures are metered per call and reads are free, so over-reporting costs money as well as clarity. As of 2026-07-26 a captured event is $0.00005 (per call) and new accounts get $2 free, roughly 39,999 captures before you pay anything. Get today’s number rather than trusting that one:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("errors.")) | {id, billing}'
Rates on this platform move down, and discount campaigns run, so what you read is likely lower than what’s printed here. The structural facts survive either way: writes are billable per event, all eight read routes are free, and a retry loop reporting four times pays four times.
The limitation you should weigh hardest is the stack trace. Whatever you put in exception is discarded — the stored event always reads {"type": "Message", "value": "<your message>", "stacktrace": []} — so there’s no frame data, no source maps, and no in-app frame markers. If minified browser traces or release health are what you need, buy Sentry and don’t argue with it. Datadog is the better pick if these errors have to sit beside APM traces and host metrics in one query language. What you get here instead is a free read surface, deterministic grouping you control, and the same key already reaching queues, cron and storage — which matters when the dedupe fix ends up being “move the report into the job that owns the failure”.