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

  1. Mint a new clientRequestId whenever the company payload changes (or after a user edit) and retry
  2. Generate ids per submission (uuid) at the moment the user confirms, and store payload+id together so retries replay byte-identical input
  3. If the original creation is what you want, fetch the existing company with the same request id instead of re-submitting different data
  4. 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

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


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)