Meet Modi
Back to Blog
·5 min

Idempotent isn't the same as honest

A user tapped accept twice and landed in a quiz flow that expected a fresh acceptance. The bug: calling accept a second time returned success silently, and success was the UI's cue to barge ahead.

By Meet Modi
IdempotencyError HandlingBackend

A user accepted a challenge invite, backed out, came back, and tapped accept again. The app dropped them straight into the quiz as if it were their first time. Submitting an answer failed with an error that had nothing to do with anything the user had just done.

It happened consistently, for every user who accepted twice, which turned out to be a normal thing to do when the confirmation screen was slow to update.

The bug: calling accept a second time was designed to succeed silently instead of erroring, on the reasoning that a repeat call shouldn't be treated as a failure. The UI treated every success the same way: proceed to the quiz.

Take 1: silence as the idempotency strategy

The function was written so that accepting an already-accepted challenge wouldn't blow up the caller. Reasonable goal. The implementation just returned success with no signal that anything different had happened the second time:

async function acceptChallenge(challengeId: string): Promise<AcceptResult> {
  const challenge = await api.getChallenge(challengeId);
  if (challenge.status === "already_accepted_by_me") {
    return { success: true }; // "idempotent": calling twice is safe
  }
  await api.postAccept(challengeId);
  return { success: true };
}

Call sequence: user taps accept, acceptChallenge runs the real path, success. User backs out, comes back, taps accept again. acceptChallenge sees already_accepted_by_me, returns success again. The caller's success handler can't tell those two calls apart, because both returned the identical { success: true }, so it runs the identical next step: navigate into the quiz.

The quiz flow at the other end assumed a specific pre-condition, that this was the participant's first entry, and set up state accordingly. On the second acceptance, that state was already set up from the first pass, and the setup ran again against state it didn't expect, so submitting an answer hit a downstream check that assumed a scenario that no longer matched the user's actual situation.

The function was idempotent in the narrow sense that calling it twice didn't corrupt any data or throw. It wasn't idempotent in the sense that actually matters to a caller: telling them the same true thing both times. The second call told a lie of omission.

Take 2: throw a specific error for every rejection case

I replaced the blanket success with a typed error for every case that isn't a fresh acceptance, including the one that used to be silently swallowed:

class AlreadyAcceptedError extends Error {}
class ChallengeInactiveError extends Error {}

async function acceptChallenge(challengeId: string): Promise<void> {
  const challenge = await api.getChallenge(challengeId);
  if (challenge.status === "already_accepted_by_me") {
    throw new AlreadyAcceptedError();
  }
  if (challenge.status === "inactive") {
    throw new ChallengeInactiveError();
  }
  await api.postAccept(challengeId);
}

Now the caller has to branch. A caught AlreadyAcceptedError shows "you've already accepted this challenge" and, if there's an in-progress attempt, offers to resume it instead of re-running first-time setup. The success path only ever runs for an acceptance that's actually happening for the first time.

What I should have done first

I conflated two different meanings of idempotent while writing the first version: safe to call more than once, and returns equivalent information each time. The first one is about not corrupting state. The second is about not misleading the caller. I only checked the first before shipping.

Next time I write a function that swallows a case into a generic success, I'm going to ask what specifically the caller does with that success, not just whether the function itself stays safe. In this case the caller's next move was a hard navigation, and hard navigations don't tolerate ambiguity about what state they're navigating into.

More Posts