Skip to content

Stop cleanly when Apple takes a code and then fails anyway - #169

Merged
parawanderer merged 2 commits into
mainfrom
fix/a-code-apple-accepted-then-failed-on
Aug 30, 2026
Merged

parawanderer merged 2 commits into
mainfrom
fix/a-code-apple-accepted-then-failed-on

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

Fixes #168.

What happened

td_2fa_submit does two things: it submits the verification code, and then runs a full Grand Slam re-authentication behind it. The reported log shows the first half succeeding and the second answering 503:

00:15:04,441  Detected 2FA requirement: trustedDeviceSecondaryAuth  →  REQUIRE_2FA
00:15:17,155  Attempting authentication for user …     ← the re-auth inside td_2fa_submit
00:15:17,663  UnhandledProtocolError: Error response for GSA request: 503

Attempting authentication for user is logged only by _gsa_authenticate (account.py:1412) and appears twice, which is what places the failure. The submit to _ENDPOINT_2FA_TD_SUBMIT never raised — the code was accepted.

Reproduced independently since. It is weather.

(The two different email spellings in that log are the same logger.info firing twice — the reporter's own inconsistent redaction, not a bug. Noted so nobody chases it.)

The actual defect is the message, not the retrying

_submit_code_with_retries caught only InvalidCredentialsError, so this escaped the loop, escaped log_in, and landed in the wizard's catch-all handler:

{type}: {message} … If this looks like a bug, please report it with that file: <issue link>

The tool asked for this issue. #168 exists because we told a user to open it about Apple having a bad minute.

Nothing is retried, and that is the finding

Three recoveries existed. Only one is known to work:

Re-type the code Cannot work — the half that succeeded spent it
Send a new code and carry on Untested. Nobody has observed this recovering
Start the sign-in again shortly What actually worked, both times

The second is the one worth being explicit about refusing. The sign-in after the reproduced 503 was refused at the password step — which looks like Apple throttling the account or the machine identity for a window, not one endpoint hiccupping. Sending two more codes into that is how a throttle becomes a lock, and it can only ever be tested on somebody's real account. Rule 15's point: the wrong remedy costs more than none.

So it stops, and says so.

Why ExportSourceError

Both front ends already show one as a plain message with no bug link — so the fix needs no new handler in either. from e keeps 503 in the message and in __cause__, so the status still reaches a log with a traceback under it.

The message also warns that the next password attempt may be refused once, because that happened and would otherwise read as a second, unrelated fault.

Tests

Seven, and all verified failable:

Break Turns red
let the protocol error escape unchanged (the original bug) 4
retry instead of stopping 4

One of those asserts retry_code is never consulted — that is what stops a later edit quietly offering the untested recovery, since a retry path that never runs looks harmless in review.

567 passed, 2 skipped across python/test and opentagviewer_export. flake8 and pyright clean.

Not fixed here

  • The Android app has the same bug. Same library, same 503, and UnhandledProtocolError reaches ErrorReportActivity — the "this one is a bug" page. Needs its own change.
  • FindMy.py folds every non-OK status into UnhandledProtocolError, carrying only the number, so nothing downstream can tell a 503 from a genuinely unmodelled response without parsing the message. Fixing that upstream would let both consumers classify properly instead of casting the wide net this PR casts.

🤖 Generated with Claude Code

parawanderer and others added 2 commits August 30, 2026 21:37
Issue #168. Apple accepted a verification code and answered 503 to the
re-authentication behind it, and the sign-in died in the wizard's
catch-all handler - which names an exception type and links the issue
tracker. So the one person who could do nothing about it was invited to
file it, and one duly did.

td_2fa_submit does two things: it submits the code, and then runs a full
GSA re-authentication. The reported log shows the first half succeeding -
"Attempting authentication for user" appears twice and only
_gsa_authenticate logs that - and the second returning 503 half a second
later. Reproduced independently since.

**It never reached the retry offer**, because only InvalidCredentialsError
was caught. That is the bug, and it is a messaging bug rather than a
retrying one.

Nothing is retried, and that is the finding. Three recoveries existed:

- Re-type the code. Cannot work; the half that succeeded spent it.
- Send a new code and carry on. **Untested.** Nobody has observed this
  recovering. The sign-in after the reproduced 503 was refused at the
  *password* step, which looks like Apple throttling the account or the
  machine identity for a while - and sending two more codes into that is
  how a throttle becomes a lock.
- Start again shortly. What actually worked, both times.

So it says that and stops, as an ExportSourceError rather than the
protocol error it came from: both front ends already show one of those as
a plain message with no invitation to report a bug, which is the entire
complaint. `from e` keeps the status code in the log, where it is the
whole diagnosis if this ever turns out to be more than weather. The
message also warns that the next password attempt may be refused once,
because that happened and would otherwise read as a second, unrelated
fault.

Seven tests. All verified failable: letting the protocol error escape
unchanged turns four red, and retrying instead of stopping turns the same
four red - including the one asserting retry_code is never consulted,
which is what stops a later edit quietly offering the untested recovery.

Not fixed here: the Android app reaches the same library and will show
its "this one is a bug" page for the same 503, and FindMy.py still folds
every non-OK status into UnhandledProtocolError, so nothing downstream
can tell a 503 from an unmodelled response without reading the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #168. Apple accepted a verification code and answered 503 to the
re-authentication behind it. The sign-in died in the wizard's catch-all
handler - the one that names an exception type and links the issue
tracker - so the one person who could do nothing about it was invited to
file it, and one duly did.

td_2fa_submit submits the code and *then* runs a full GSA
re-authentication. The reported log shows the first half succeeding -
"Attempting authentication for user" appears twice and only
_gsa_authenticate logs that - and the second failing half a second later.
Reproduced since.

**Nothing the user can type helps.** The code is spent on the half that
worked, so re-typing it is a guaranteed second failure, and there is no
question worth putting to somebody with no way to judge the answer. The
only recovery anyone has observed is time passing. So it waits, asks
Apple for a new code itself, and prompts for that.

The durations are the finding rather than a round number. The one
recovery went: 503; then a whole manual round of re-typing the Apple ID
and password, which was refused at the *password* step; then another
round, which worked. A manual round is the better part of a minute, so
roughly a minute after the failure the account was still being refused,
and what eventually worked was about two rounds out. Hence 60s and then
120s, and a test that fails if either is quietly lowered.

Strictly the refusal in the middle was on the password call rather than
the 2FA call, so it does not prove a new code would have been rejected at
that moment. It is the only measurement there is.

The new code is requested *after* each wait, not before: Apple's codes
expire, and one fetched first would be two minutes stale by the time it
is typed.

**And the waiting is visible, which needed a small thing in asyncui.**
A progress window that says "Signing in to iCloud..." for three minutes
is indistinguishable from a hung one, and this is a deliberate wait
rather than a slow call. Asker.say now rewrites that line from the worker
thread, so it counts down; the CLI rewrites one line on stderr. Without
that, the fix would read as the bug it replaced.

When both waits are spent it stops with a message saying so - as an
ExportSourceError, which both front ends already show plainly with no
invitation to report a bug, which was the entire complaint. It also warns
that the next password attempt may be refused once, because that happened
and otherwise reads as a second, unrelated fault. `from e` keeps 503 in
the message and in __cause__.

The wizard titles it "Could not finish signing in" rather than the
generic "Could not read your accessories": nothing was read, and a dialog
that misdescribes what happened is worse than a vague one.

Nineteen tests across three files, all verified failable: giving up
immediately turns three red, requesting the code before the wait turns
one, and lowering the first wait to 20s turns one.

Not fixed here: the Android app reaches the same library and sends the
same 503 to its "this one is a bug" page. Same finding, its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer
parawanderer merged commit 0d3aa6e into main Aug 30, 2026
5 checks passed
parawanderer added a commit that referenced this pull request Aug 30, 2026
Fixes the sign-in that ended for good when Apple returned 503 immediately
after accepting a verification code (#168, #169).

Nothing under opentagviewer_export/ changed, so what a bundle is is
unchanged and this does not wait on an app release - AGENTS.md rule 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer added a commit to ubrt/OpenTagViewer that referenced this pull request Sep 3, 2026
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 parawanderer#168 and reproduced since. The desktop exporter fixed its half
through the same library in parawanderer#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>
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