Stop cleanly when Apple takes a code and then fails anyway - #169
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #168.
What happened
td_2fa_submitdoes 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 answering503:Attempting authentication for useris logged only by_gsa_authenticate(account.py:1412) and appears twice, which is what places the failure. The submit to_ENDPOINT_2FA_TD_SUBMITnever raised — the code was accepted.Reproduced independently since. It is weather.
(The two different email spellings in that log are the same
logger.infofiring 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_retriescaught onlyInvalidCredentialsError, so this escaped the loop, escapedlog_in, and landed in the wizard's catch-all handler: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:
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
ExportSourceErrorBoth front ends already show one as a plain message with no bug link — so the fix needs no new handler in either.
from ekeeps503in 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:
One of those asserts
retry_codeis 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 skippedacrosspython/testandopentagviewer_export. flake8 and pyright clean.Not fixed here
UnhandledProtocolErrorreachesErrorReportActivity— the "this one is a bug" page. Needs its own change.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