koala73/worldmonitor · error · ConvexError
REPLAY_CONFLICT
REPLAY_CONFLICT
Error message
REPLAY_CONFLICT
What it means
Thrown by createCompanyForOwner (convex/companyMonitoring/companies.ts:123) when a company record already exists with the same ownerAccountId + clientRequestId (via by_account_directRequestId), but the stored directFingerprint — computed as fingerprint({ version: "cm-direct-v1", company }) over the normalized company input — differs from the current request's. clientRequestId is an idempotency key: reusing it with a different company payload is a conflict, not a replay.
Solutions
- Mint a new clientRequestId whenever the company payload changes (or after a user edit) and retry
- Generate ids per submission (uuid) at the moment the user confirms, and store payload+id together so retries replay byte-identical input
- If the original creation is what you want, fetch the existing company with the same request id instead of re-submitting different data
- Audit retry middleware to confirm it freezes both the id and the payload snapshot
Example fix
// before
async function createCompany(company, clientRequestId) {
return api.mutate({ clientRequestId, company }); // id reused after user edits company
}
// after
let lastPayloadHash = null;
async function createCompany(company, clientRequestId) {
const payloadHash = await fingerprint({ version: "cm-direct-v1", company });
const id = payloadHash === lastPayloadHash ? clientRequestId : newRequestId();
lastPayloadHash = payloadHash;
return api.mutate({ clientRequestId: id, company });
} Defensive patterns
Strategy: validation
Validate before calling
// Bind the idempotency key to the exact payload: mint a new id when the payload changes.
const payloadKey = await fingerprint({ version: "cm-direct-v1", company });
let clientRequestId = idStore.get(payloadKey);
if (!clientRequestId) {
clientRequestId = crypto.randomUUID();
idStore.set(payloadKey, clientRequestId);
}
await api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company }); Try / catch
try {
await api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company });
} catch (err) {
if (err instanceof ConvexError && err.data === "REPLAY_CONFLICT") {
clientRequestId = crypto.randomUUID(); // payload genuinely differs from the stored replay
await api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company });
return;
}
throw err;
} Prevention
- Generate clientRequestId per submission and persist it with the exact payload sent
- On user edits after a failure, mint a fresh id instead of reusing the old one
- Never share fixed ids across test cases with different payloads
- On REPLAY_CONFLICT, decide deliberately: new id (new intent) or fetch the stored company (same intent)
When it happens
Trigger: A retry framework reusing the same clientRequestId after the user edited the company form (name, domicileCountry, customerReference, or claims changed); two different companies accidentally sent with one shared request id (e.g. a loop variable bug); regenerating request ids from a hash of the user session instead of the submission; copy-pasting an integration test's fixed id across payloads.
Common situations: Mobile/web clients with a retry button that does not mint a fresh id after payload edits; queue redelivery where the payload is rebuilt with defaults applied differently; test suites hardcoding clientRequestId; idempotency keys derived from a counter that resets on redeploy.
Related errors
- COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT
- CUSTOMER_REFERENCE_CONFLICT
- ALREADY_REVOKED
- CLAIM_NOT_FOUND
- COMPANY_LIMIT_REACHED
AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-08-21).
Data as JSON: /api/errors/fb77308e6c2942fd.
Report an issue: GitHub.
Appendix: source
Thrown at convex/companyMonitoring/companies.ts:123
ownerUserId: v.string(),
clientRequestId: v.string(),
company: monitoredCompanyInputValidator,
},
handler: async (ctx, args) => {
const account = await requireProvisionedAccount(ctx, args.ownerUserId);
const clientRequestId = normalizeRequestId(args.clientRequestId);
const company = normalizeMonitoredCompanyInput(args.company as MonitoredCompanyInput);
const directFingerprint = await fingerprint({ version: "cm-direct-v1", company });
const replay = await ctx.db
.query("companyMonitoringCompanies")
.withIndex("by_account_directRequestId", (q) =>
q.eq("ownerAccountId", account.logicalAccountId).eq("directRequestId", clientRequestId),
)
.unique();
if (replay) {
if (replay.directFingerprint !== directFingerprint) {
throw new ConvexError("REPLAY_CONFLICT");
}
return { status: "replayed", companyId: replay.companyId };
}
const noop = await findNoopByCustomerReference(
ctx,
account.logicalAccountId,
company.customerReference,
);
if (noop) return { status: "noop", companyId: noop.companyId };
const companyCount = account.companyCount ?? 0;
if (companyCount >= (account.companyLimit ?? COMPANY_LIMIT)) {
throw new ConvexError("COMPANY_LIMIT_REACHED");
}
const companyId = await insertNormalizedCompany(ctx, account, company, {
directRequestId: clientRequestId,View on GitHub (pinned to 7d06c8633d)