Skip to content

Stop asking for a verification code Apple has already spent - #170

Merged
parawanderer merged 1 commit into
mainfrom
fix/a-code-apple-already-took
Aug 31, 2026
Merged

Stop asking for a verification code Apple has already spent#170
parawanderer merged 1 commit into
mainfrom
fix/a-code-apple-already-took

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

Fixes #168. The desktop exporter's half of the same bug was #169; this is deliberately reasoned the same way rather than invented differently, from the handover note that came with it.

What happens

FindMy.py's 2FA submit does two things: it sends the code — that is the check, and it passes — and then runs a full Grand Slam re-authentication, which can 503 on its own. By then Apple has consumed the code.

Detected 2FA requirement: trustedDeviceSecondaryAuth
Attempting authentication for user …          <- the re-auth inside td_2fa_submit
UnhandledProtocolError: Error response for GSA request: 503

So the user's code was accepted and is now spent, and re-typing it cannot work.

Why the old behaviour made it worse

The screen said "Two-Factor Authentication failed", cleared the box and asked for the code again — the one action guaranteed to fail. It compounds:

  1. The retry returns InvalidCredentialsError, a different error, which reads as a typo.
  2. failed2FAAttemptCount climbs.
  3. Past three, the screen advises changing the Anisette server — wrong here and expensive: Anisette had no part in it, and changing it forces a re-login against a different machine identity (AGENTS.md rule 4). Somebody is sent to fix something that was never broken.

What it does now

  • Classify before counting. A fault on Apple's side never touches the attempt counter, so it can never reach the Anisette advice.
  • Say Apple took the code, rather than blaming the code.
  • Wait, then request a new one — after the wait, never before. Both orders look right in a diff; Apple's codes expire, so one fetched first is two minutes stale by the time it is typed.
  • No prompt. Re-typing cannot work and waiting is the only option, so a dialog would offer a choice between one real answer and a wrong one. The exporter reached the same conclusion.
  • The wait counts down on screen. Two minutes of a still screen on a phone is indistinguishable from a hang.
  • Two goes, then it says plainly this is Apple's fault — and warns the password may be refused once, which happened in the observed recovery and otherwise reads as a second, unrelated problem.
  • No re-entering the Apple ID and password. The account is still in its second-factor state, so requesting on the chosen method is all that is needed.

The waits are 60s then 120s, and there is a test that goes red if they drop

They are a measurement, not round numbers. The one observed recovery went: the 503; a full manual round — Apple ID, password, choose delivery, wait, type — which was refused at the password step; then another round that worked. A round is the better part of a minute, so the account was still refusing about a minute after the 503, and what worked was roughly two rounds out.

The honest caveat, carried over: that middle refusal was on the password call rather than the 2FA call, so it does not strictly prove a new code would have been rejected at that instant. It is the only measurement there is and it points one way. ACodeAppleAlreadyTookTest fails if either number is lowered, with the reasoning attached, so shortening them has to be a decision rather than a tidy-up.

Not done: the upstream fix

_gsa_request folds every non-OK status into UnhandledProtocolError carrying only the number, and findmy/errors.py has no transient-failure type — so the classification here matches on the message. A proper type in the fork would let both consumers classify cleanly, but it means re-pinning FindMy.py in all four places (rule 14). Same call as #169: left out deliberately.

The net is wide on purpose. Everything it catches happened after the submit returned, so the code is gone in all of them, and the cost of being wrong runs one way: treating a spent code as a typo sends somebody back to type it again and ends in bad Anisette advice, while treating a typo as a spent code costs a wait and a fresh code.

Verified

  • 645 tests, 0 failed, 22 skipped; JVM suite green; 305 strings across all ten locales
  • Checked both ways on a device: with the classification disabled, two of the three screen tests go red
  • Not verified: a real 503 from Apple. It cannot be arranged on demand, which is why the fake produces the exact message that arrives across the bridge

Branched off main and independent of #139.

🤖 Generated with Claude Code

FindMy.py's 2FA submit does two things: it sends the code, which passes, and
then runs a full Grand Slam re-authentication, which can 503 on its own. By
then Apple has consumed the code - so the screen's response, "Two-Factor
Authentication failed", an empty box and try again, was the one action
guaranteed to fail.

It compounds. The retry comes back as InvalidCredentialsError, a different
error that reads as a typo, and failed2FAAttemptCount climbs until the screen
advises changing the Anisette server. Anisette had no part in it, and changing
it forces a re-login against a different machine identity (rule 4): somebody is
sent to fix something that was never broken.

Reported as #168 and reproduced since. The desktop exporter fixed its half
through the same library in #169; this is reasoned the same way rather than
invented differently, from the handover note.

So: classify before counting, say Apple took the code, wait, then request a
*new* one - after the wait, never before, because Apple's codes expire and one
fetched first is two minutes stale by the time it is typed. No prompt: there is
no question worth asking when re-typing cannot work and waiting is the only
option. The wait counts down on screen, because two minutes of a still screen
on a phone is indistinguishable from a hang. Two goes, then it says plainly
that this is Apple's fault - and warns the password may be refused once, which
happened in the one observed recovery and otherwise reads as a second,
unrelated problem.

The Apple ID and password are not asked for again: the account is still in its
second-factor state, so requesting on the chosen method is all that is needed.

The waits are 60s then 120s, and ACodeAppleAlreadyTookTest goes red if either
drops. They are a measurement, not round numbers - the observed recovery was
still being refused about a minute after the 503 and worked about two rounds
out - and the test carries the reasoning so lowering them has to be a decision
rather than a tidy-up.

Checked both ways on a device: two of the three screen tests go red with the
classification disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer
parawanderer merged commit 820102f into main Aug 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exporter: 1.4.0

1 participant