diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8864292..29c82b4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -407,7 +407,7 @@ chaquopy { // wheel for desktop platforms and a pure-Python `py3-none-any` one as well. // There is no Android wheel, so pip falls back to the pure-Python build - which // is correct but markedly slower. The messages here are small enough not to care. - install("git+https://github.com/parawanderer/FindMy.py@b6f544bc673b66adeac9e8315f8ef499c4956036") + install("git+https://github.com/parawanderer/FindMy.py@bcb078761085c078eadac477da39a4c85b1f3a44") install("NSKeyedUnArchiver==1.5") diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java index 03567a97..8fb9ed21 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java @@ -48,6 +48,7 @@ import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.python.PythonAuthService.AuthMethodPhone; +import dev.wander.android.opentagviewer.util.HowLongToWait; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; /** @@ -347,6 +348,37 @@ public void appleDecliningIsNotBlamedOnThePasswordOrTheNetwork() { not(withText(containsString("Grand Slam"))))); } + /** + * When Apple names a wait, the screen gives it rather than "try again shortly". + * + *
A {@code Retry-After} on the refusal, carried across the bridge. The test above is the + * other half - no header, which is every refusal observed so far - and it pins that no number + * is invented then. + */ + @Test + public void aWaitAppleNamedIsShownRoundedUp() { + this.apple = FakeAppleAuthService.appleAsksToWait(90); + AppDependencies.replaceAuthService(this.apple); + + launch(); + signIn(); + + final android.content.Context context = getInstrumentation().getTargetContext(); + final java.util.Locale locale = context.getResources().getConfiguration().getLocales().get(0); + + // The device's ICU, not a string this test composed: 90 seconds is two minutes, not one. + assertEquals("2 minutes", HowLongToWait.of(90).describe(java.util.Locale.ENGLISH)); + + Eventually.check(() -> onView(withId(R.id.login_error_message_text)).check(matches( + withText(context.getString(R.string.login_failed_apple_asked_to_wait, + HowLongToWait.of(90).describe(locale)))))); + + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(context.getString(R.string.login_failed_apple_declined))))); + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(containsString("429"))))); + } + /** * A failed sign-in can produce a log without a second machine. * diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java index 26bd37e7..416ec404 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java @@ -130,12 +130,30 @@ public static FakeAppleAuthService appleIsDeclining() { new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null); fake.loginFailsWith = new PythonAccountLoginException( "The Grand Slam request was refused with HTTP 503. This is Apple declining to" - + " serve the request rather than a response this library cannot read," - + " and it usually clears on its own -- wait and try again.", + + " serve the request rather than a response this library cannot read." + + " Waiting and trying again may help.", PythonAccountLoginException.REASON_APPLE_DECLINED); return fake; } + /** + * Apple declining, and saying how long to leave it - a {@code Retry-After} on the refusal. + * + *
Not yet seen from Grand Slam, which is why {@link #appleIsDeclining()} carries no + * wait: that is the case people actually meet. This is the one where Apple names a time. + */ + public static FakeAppleAuthService appleAsksToWait(final double seconds) { + final FakeAppleAuthService fake = + new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null); + fake.loginFailsWith = new PythonAccountLoginException( + "The Grand Slam request was refused with HTTP 429. This is Apple declining to" + + " serve the request rather than a response this library cannot read.", + PythonAccountLoginException.REASON_APPLE_DECLINED, + null, + seconds); + return fake; + } + /** Signing in works, but the code that gets typed is refused. */ public FakeAppleAuthService thatRejectsTheCode(final String message) { this.codeFailsWith = new PythonAccountLoginException(message); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index c76a9d7c..c0d2d6b7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -73,6 +73,7 @@ import dev.wander.android.opentagviewer.ui.login.StepTransition.Direction; import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog; import dev.wander.android.opentagviewer.ui.settings.SharedMainSettingsManager; +import dev.wander.android.opentagviewer.util.HowLongToWait; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.rx.ACodeAppleAlreadyTook; @@ -762,6 +763,15 @@ private String describeLoginFailure(final Throwable error) { // 503" verbatim.** That is accurate and reads as a bug in this app, which is how issue // #176 came to be filed. Nothing about the Apple ID or the password is wrong here. if (PythonAccountLoginException.REASON_APPLE_DECLINED.equals(reason)) { + // Apple's own wait replaces the "try again shortly" advice when it named one - it is + // the only reliable answer to "how long". It usually has not, and then nothing here + // invents a number: a guess too short is refused again. + final Double wait = ((PythonAccountLoginException) error).getRetryAfterSeconds(); + if (wait != null) { + return this.getString(R.string.login_failed_apple_asked_to_wait, + HowLongToWait.of(wait).describe( + this.getResources().getConfiguration().getLocales().get(0))); + } return this.getString(R.string.login_failed_apple_declined); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java index fd2676c2..4c67849a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java @@ -55,6 +55,16 @@ public class PythonAccountLoginException extends RuntimeException { */ private final transient PyObject account; + /** + * Seconds Apple asked for before another attempt, or null when it did not say. + * + *
Null is the usual value, and means unknown rather than "retry now". It comes from
+ * a {@code Retry-After} header, and no refusal from Grand Slam has been seen carrying one -
+ * so nothing may substitute a number for it. When present, it is the only reliable advice
+ * there is about how long to leave it.
+ */
+ private final Double retryAfterSeconds;
+
public PythonAccountLoginException(String message) {
this(message, REASON_UNKNOWN);
}
@@ -64,21 +74,29 @@ public PythonAccountLoginException(String message, String reason) {
}
public PythonAccountLoginException(String message, String reason, PyObject account) {
+ this(message, reason, account, null);
+ }
+
+ public PythonAccountLoginException(
+ String message, String reason, PyObject account, Double retryAfterSeconds) {
super(message);
this.reason = reason == null || reason.isBlank() ? REASON_UNKNOWN : reason;
this.account = account;
+ this.retryAfterSeconds = retryAfterSeconds;
}
public PythonAccountLoginException(String message, Throwable cause) {
super(message, cause);
this.reason = REASON_UNKNOWN;
this.account = null;
+ this.retryAfterSeconds = null;
}
public PythonAccountLoginException(Throwable cause) {
super(cause);
this.reason = REASON_UNKNOWN;
this.account = null;
+ this.retryAfterSeconds = null;
}
/** Which kind of failure this was, for choosing what to show. Never null. */
@@ -91,6 +109,11 @@ public PyObject getAccount() {
return this.account;
}
+ /** How long Apple asked the user to wait, in seconds, or null. See the field. */
+ public Double getRetryAfterSeconds() {
+ return this.retryAfterSeconds;
+ }
+
/** Whether this is a terms failure that can actually be recovered from in the app. */
public boolean hasTermsToAccept() {
return REASON_TERMS.equals(this.reason) && this.account != null;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java
index 23c6ad4d..a912d778 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java
@@ -69,10 +69,14 @@ public static Observable Always rounded up. Somebody told to come back too early is refused again, and then
+ * trusts the number less - so 61 seconds is "2 minutes", never "1 minute".
+ *
+ * Seconds below a minute, minutes below two hours, hours after that. The desktop exporter's
+ * {@code _a_wait_a_person_reads} draws the same lines, so the two say the same thing about the
+ * same header.
+ *
+ * The rounding is here and plain Java so the JVM suite can pin it; the wording comes from
+ * {@code android.icu}, which knows every locale's plural forms and so needs no string per unit.
+ */
+public final class HowLongToWait {
+
+ public enum Unit { SECONDS, MINUTES, HOURS }
+
+ private static final long MINUTE = 60;
+ private static final long HOUR = 60 * MINUTE;
+
+ private final long amount;
+ private final Unit unit;
+
+ private HowLongToWait(final long amount, final Unit unit) {
+ this.amount = amount;
+ this.unit = unit;
+ }
+
+ public static HowLongToWait of(final double seconds) {
+ final long whole = Math.max(0, (long) Math.ceil(seconds));
+ if (whole < MINUTE) {
+ return new HowLongToWait(whole, Unit.SECONDS);
+ }
+ if (whole < 2 * HOUR) {
+ return new HowLongToWait(ceilDiv(whole, MINUTE), Unit.MINUTES);
+ }
+ return new HowLongToWait(ceilDiv(whole, HOUR), Unit.HOURS);
+ }
+
+ public long getAmount() {
+ return this.amount;
+ }
+
+ public Unit getUnit() {
+ return this.unit;
+ }
+
+ /** "2 minutes", "2 Minuten", "2 分钟" - in the given locale, with its own plural rules. */
+ public String describe(final Locale locale) {
+ final MeasureUnit measured;
+ switch (this.unit) {
+ case SECONDS: measured = MeasureUnit.SECOND; break;
+ case MINUTES: measured = MeasureUnit.MINUTE; break;
+ default: measured = MeasureUnit.HOUR; break;
+ }
+ return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE)
+ .format(new Measure(this.amount, measured));
+ }
+
+ private static long ceilDiv(final long x, final long y) {
+ return (x + y - 1) / y;
+ }
+}
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index 1d97d8cd..197060b2 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -362,6 +362,11 @@ def loginSync(email: str, password: str, anisetteServerUrl: str,
"reason": reason,
}
+ # Absent rather than zero when Apple did not say, because zero reads as "retry now".
+ wait = appleAskedToWait(e)
+ if wait is not None:
+ failure["retryAfterSeconds"] = wait
+
# **The account survives a terms failure, and only a terms failure.**
#
# Authentication itself worked here - it is the delegate exchange after it that did not
@@ -465,9 +470,13 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str | None:
**A 503 from Grand Slam reads as a rejected sign-in to everybody who meets it**, because the only
thing on screen is a failure at the moment a password was entered. It is neither: nothing was
-wrong with the credentials, nothing was reached and refused on their merits, and it clears on its
-own within minutes. Reported as OpenTagViewer#176, where one account met the same 503 three times
-in three minutes at three different call sites.
+wrong with the credentials, and nothing was reached and refused on their merits. Reported as
+OpenTagViewer#176, where one account met the same 503 three times in three minutes at three
+different call sites.
+
+**It does not reliably clear on its own**, so nothing on screen promises that. The 503s of
+September 2026 were Apple refusing the Xcode client identifier, and lasted until the client
+changed; a 429 has been seen to outlast a reinstall.
Distinguished from :data:`REASON_NETWORK` because the advice differs. A network failure is
usually the phone's - check the connection. This one is Apple's, and checking anything at this
@@ -521,6 +530,20 @@ def classifyLoginFailure(error: BaseException) -> str:
return REASON_UNKNOWN
+def appleAskedToWait(error: BaseException) -> float | None:
+ """
+ Seconds Apple asked for before another attempt, or None when it did not say.
+
+ **None is the usual answer.** It comes from a `Retry-After` header, and no refusal from Grand
+ Slam has been seen carrying one - so the screen treats None as "unknown" and says nothing
+ about a wait, rather than inventing one. When Apple does name a time it is the only reliable
+ advice available, which is why it is carried at all.
+ """
+ if isinstance(error, AppleServiceUnavailableError):
+ return error.retry_after
+ return None
+
+
def describeLoginFailure(error: BaseException) -> str:
"""
A detail string that is **never empty**.
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index bae2aab4..2e3beb15 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -387,4 +387,5 @@ Es wurde kein Verlauf importiert und bereits gespeicherte Daten wurden nicht ge
The cases are the exporter's, from {@code test_apple_declining.py}, so the phone and the
+ * desktop say the same thing about the same header. Pure arithmetic, so JVM - rule 13.
+ */
+public class HowLongToWaitTest {
+
+ private static void assertWait(double seconds, long amount, HowLongToWait.Unit unit) {
+ final HowLongToWait wait = HowLongToWait.of(seconds);
+ assertEquals(seconds + "s", amount, wait.getAmount());
+ assertEquals(seconds + "s", unit, wait.getUnit());
+ }
+
+ @Test
+ public void underAMinuteIsSeconds() {
+ assertWait(0, 0, HowLongToWait.Unit.SECONDS);
+ assertWait(1, 1, HowLongToWait.Unit.SECONDS);
+ assertWait(45, 45, HowLongToWait.Unit.SECONDS);
+ }
+
+ /** Told to come back too early, a person is refused again and trusts the number less. */
+ @Test
+ public void itRoundsUpNeverDown() {
+ assertWait(0.2, 1, HowLongToWait.Unit.SECONDS);
+ assertWait(59.2, 1, HowLongToWait.Unit.MINUTES);
+ assertWait(61, 2, HowLongToWait.Unit.MINUTES);
+ assertWait(90, 2, HowLongToWait.Unit.MINUTES);
+ assertWait(7201, 3, HowLongToWait.Unit.HOURS);
+ }
+
+ @Test
+ public void minutesUntilTwoHoursThenHours() {
+ assertWait(60, 1, HowLongToWait.Unit.MINUTES);
+ assertWait(7199, 120, HowLongToWait.Unit.MINUTES);
+ assertWait(7200, 2, HowLongToWait.Unit.HOURS);
+ }
+
+ /** A date already past arrives as 0 from Python; nothing should make it negative here. */
+ @Test
+ public void aNegativeWaitIsNone() {
+ assertWait(-5, 0, HowLongToWait.Unit.SECONDS);
+ }
+}
diff --git a/app/src/test/python/conftest.py b/app/src/test/python/conftest.py
index c5737097..f3f84e1f 100644
--- a/app/src/test/python/conftest.py
+++ b/app/src/test/python/conftest.py
@@ -8,6 +8,8 @@
import sys
from pathlib import Path
+import pytest
+
APP_PYTHON = Path(__file__).resolve().parents[2] / "main" / "python"
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@@ -27,3 +29,38 @@
def pytest_configure(config):
config.addinivalue_line("markers", "fixture_required: needs a redacted beacon plist")
+
+
+class FakeSigningIn:
+ """An account whose `login` raises, for driving `loginSync`'s failure path."""
+
+ def __init__(self, raising: BaseException) -> None:
+ from findmy.reports.state import LoginState
+
+ self._raising = raising
+ self.login_state = LoginState.LOGGED_OUT
+
+ def login(self, email: str, password: str):
+ raise self._raising
+
+
+@pytest.fixture
+def signingIn(monkeypatch):
+ """
+ `loginSync`, with the Anisette and identity machinery stubbed out.
+
+ Returns the account the attempt was made with and what `loginSync` handed Java. Shared here
+ because more than one module needs to ask what a particular failure looks like from Java.
+ """
+ import main
+
+ def attempt(raising: BaseException):
+ account = FakeSigningIn(raising)
+ monkeypatch.setattr(main, "AppleAccount", lambda *a, **k: account)
+ monkeypatch.setattr(main, "_anisetteProvider", lambda *a, **k: object())
+ monkeypatch.setattr(main.app_identity, "identityForNewSession", lambda _: {})
+ monkeypatch.setattr(main.app_identity, "deviceIdsForNewSession", lambda _: {})
+
+ return account, main.loginSync("someone@example.com", "hunter2", "https://ani.example")
+
+ return attempt
diff --git a/app/src/test/python/requirements.txt b/app/src/test/python/requirements.txt
index f43b0747..3d9b2cec 100644
--- a/app/src/test/python/requirements.txt
+++ b/app/src/test/python/requirements.txt
@@ -9,7 +9,7 @@
# `FindMy==0.9.8` here for as long as the app built the fork, so every bridge test
# ran against a library the app does not ship - which is not a small difference:
# the fork's Anisette providers take `serial=` and PyPI's do not.
-git+https://github.com/parawanderer/FindMy.py@b6f544bc673b66adeac9e8315f8ef499c4956036
+git+https://github.com/parawanderer/FindMy.py@bcb078761085c078eadac477da39a4c85b1f3a44
NSKeyedUnArchiver==1.5
PyYAML==6.0.2
diff --git a/app/src/test/python/test_apple_asked_to_wait.py b/app/src/test/python/test_apple_asked_to_wait.py
new file mode 100644
index 00000000..ab4a9ab1
--- /dev/null
+++ b/app/src/test/python/test_apple_asked_to_wait.py
@@ -0,0 +1,57 @@
+"""
+Carrying Apple's own "try again in N" from a refused sign-in to the screen.
+
+**Absent is the case that matters most**, because it is the only one observed. No refusal from
+Grand Slam has been seen with a `Retry-After` header - not the 503s of September 2026, not the
+429 that followed them - so what these tests protect first is that a missing header stays
+missing, all the way across, instead of becoming a zero that reads as "retry now".
+"""
+
+from __future__ import annotations
+
+from findmy.errors import AppleServiceUnavailableError
+
+import main
+
+
+class TestTheWaitCrossesTheBridge:
+ def test_a_wait_apple_named_reaches_java(self, signingIn):
+ _, answer = signingIn(
+ AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=90.0),
+ )
+
+ assert answer["reason"] == main.REASON_APPLE_DECLINED
+ assert answer["retryAfterSeconds"] == 90.0
+
+ def test_no_wait_is_no_key_rather_than_zero(self, signingIn):
+ """Zero is a real answer, meaning "now", and Apple did not give it."""
+ _, answer = signingIn(AppleServiceUnavailableError(429, "The Grand Slam request"))
+
+ assert answer["reason"] == main.REASON_APPLE_DECLINED
+ assert "retryAfterSeconds" not in answer
+
+ def test_a_wait_of_zero_is_still_carried(self, signingIn):
+ """A date already past arrives as 0, and is not the same as no answer."""
+ _, answer = signingIn(
+ AppleServiceUnavailableError(503, "The Grand Slam request", retry_after=0.0),
+ )
+
+ assert answer["retryAfterSeconds"] == 0.0
+
+ def test_other_failures_carry_no_wait(self, signingIn):
+ _, answer = signingIn(TimeoutError())
+
+ assert "retryAfterSeconds" not in answer
+
+
+class TestReadingTheWait:
+ def test_it_is_read_off_the_error(self):
+ error = AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=45.0)
+
+ assert main.appleAskedToWait(error) == 45.0
+
+ def test_it_is_none_when_apple_did_not_say(self):
+ assert main.appleAskedToWait(AppleServiceUnavailableError(429, "x")) is None
+
+ def test_it_is_none_for_anything_else(self):
+ assert main.appleAskedToWait(RuntimeError("Retry-After: 30")) is None
diff --git a/app/src/test/python/test_terms_flow.py b/app/src/test/python/test_terms_flow.py
index 31220a8e..a967eb97 100644
--- a/app/src/test/python/test_terms_flow.py
+++ b/app/src/test/python/test_terms_flow.py
@@ -308,33 +308,6 @@ def test_a_second_fetch_forgets_the_first(self):
assert not json.loads(main.acceptTerms(account, "iCloud"))["ok"]
-class FakeSigningIn:
- """An account whose `login` raises, for driving `loginSync`'s failure path."""
-
- def __init__(self, raising: BaseException) -> None:
- self._raising = raising
- self.login_state = LoginState.LOGGED_OUT
-
- def login(self, email: str, password: str):
- raise self._raising
-
-
-@pytest.fixture
-def signingIn(monkeypatch):
- """`loginSync`, with the Anisette and identity machinery stubbed out."""
-
- def attempt(raising: BaseException):
- account = FakeSigningIn(raising)
- monkeypatch.setattr(main, "AppleAccount", lambda *a, **k: account)
- monkeypatch.setattr(main, "_anisetteProvider", lambda *a, **k: object())
- monkeypatch.setattr(main.app_identity, "identityForNewSession", lambda _: {})
- monkeypatch.setattr(main.app_identity, "deviceIdsForNewSession", lambda _: {})
-
- return account, main.loginSync("someone@example.com", "hunter2", "https://ani.example")
-
- return attempt
-
-
class TestTheAccountSurvivesLongEnoughToAgree:
"""
The seam the screen depends on, and the one that is easy to leave out.
diff --git a/python/exporter/icloud.py b/python/exporter/icloud.py
index 8af7032f..f0b72628 100644
--- a/python/exporter/icloud.py
+++ b/python/exporter/icloud.py
@@ -20,6 +20,7 @@
import asyncio
import base64
import logging
+import math
import uuid
from io import BytesIO
from pathlib import Path
@@ -615,7 +616,22 @@ def apple_is_declining(error: BaseException) -> AppleServiceUnavailableError | N
def describe_apple_declining(error: AppleServiceUnavailableError) -> str:
- """What to tell somebody whose sign-in was refused by Apple rather than by their password."""
+ """
+ What to tell somebody whose sign-in was refused by Apple rather than by their password.
+
+ **Apple's own wait comes first when it named one**, from a `Retry-After` header, because it
+ is the only reliable advice there is. No refusal from Grand Slam has been seen carrying one,
+ so the usual message is the one without it - and that one invents no number.
+ """
+ if error.retry_after is not None:
+ return (
+ f"Apple's sign-in service refused the request with HTTP {error.status_code}, and asked"
+ f" for {_a_wait_a_person_reads(error.retry_after)} before trying again.\n\n"
+ "Apple declined it rather than anything being wrong with your Apple ID, your password"
+ " or your verification code. Nothing was changed and nothing was sent.\n\n"
+ "Signing in again sooner than that is likely to be refused the same way."
+ )
+
return (
f"Apple's sign-in service refused the request with HTTP {error.status_code}.\n\n"
"Apple declined it rather than anything being wrong with your Apple ID, your password or"
@@ -627,6 +643,18 @@ def describe_apple_declining(error: AppleServiceUnavailableError) -> str:
)
+def _a_wait_a_person_reads(seconds: float) -> str:
+ """Seconds, minutes or hours, rounded up so nobody is told to come back too early."""
+ whole = max(0, math.ceil(seconds))
+ if whole < 60:
+ amount, unit = whole, "second"
+ elif whole < 2 * 60 * 60:
+ amount, unit = math.ceil(whole / 60), "minute"
+ else:
+ amount, unit = math.ceil(whole / (60 * 60)), "hour"
+ return f"{amount} {unit}{'' if amount == 1 else 's'}"
+
+
def _apple_failed_after_taking_the_code(error: BaseException) -> SignInInterrupted:
"""
What to tell somebody whose code was accepted and whose sign-in failed anyway.
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 7ac98da4..b3581dfa 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -69,7 +69,7 @@ constraint-dependencies = [
]
[tool.uv.sources]
-FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "b6f544bc673b66adeac9e8315f8ef499c4956036" }
+FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "bcb078761085c078eadac477da39a4c85b1f3a44" }
[dependency-groups]
# Only the release build installs this, with `uv sync --no-default-groups --group build`. It is
diff --git a/python/test/test_apple_declining.py b/python/test/test_apple_declining.py
index 0150c350..5ce17339 100644
--- a/python/test/test_apple_declining.py
+++ b/python/test/test_apple_declining.py
@@ -126,3 +126,52 @@ def test_itdoesNotPromiseItWillClear(self, message):
def test_itdoesNotAskForABugReportOutright(self, message):
"""The behaviour being fixed."""
assert "github.com" not in message.lower()
+
+
+class TestWhenAppleNamesAWait:
+ """
+ **Absent is the observed case and the one that has to stay honest.** No refusal from Grand
+ Slam has been seen with a `Retry-After` header, so everything above - the message without a
+ wait - is what people actually read. This is for the day Apple says how long.
+ """
+
+ @staticmethod
+ def told(seconds: float) -> str:
+ return icloud.describe_apple_declining(
+ AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=seconds),
+ )
+
+ def test_itsaysHowLongAppleAskedFor(self):
+ assert "asked for 2 minutes before trying again" in self.told(90)
+
+ def test_itkeepsTheStatusAndClearsTheCredentials(self):
+ message = self.told(90)
+
+ assert "429" in message
+ assert "password" in message
+ assert "nothing was changed" in message.lower()
+
+ def test_itdropsTheAdviceToTryAgainShortly(self):
+ """Apple has just said when. "Shortly" would contradict it."""
+ assert "shortly" not in self.told(90)
+
+ @pytest.mark.parametrize(("seconds", "said"), [
+ (0, "0 seconds"),
+ (1, "1 second"),
+ (59.2, "1 minute"),
+ (60, "1 minute"),
+ (61, "2 minutes"),
+ (7199, "120 minutes"),
+ (7200, "2 hours"),
+ (7201, "3 hours"),
+ ])
+ def test_itroundsUpNeverDown(self, seconds, said):
+ """Told to come back too early, a person is refused again and trusts the number less."""
+ assert f"asked for {said} before" in self.told(seconds)
+
+ def test_withoutOneItNamesNoWaitAtAll(self):
+ """No invented number when the header was absent."""
+ message = icloud.describe_apple_declining(a_503())
+
+ assert "asked for" not in message
+ assert "minute" not in message
diff --git a/python/uv.lock b/python/uv.lock
index 0a543aa1..e1635ec9 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -659,7 +659,7 @@ wheels = [
[[package]]
name = "findmy"
version = "0.10.1"
-source = { git = "https://github.com/parawanderer/FindMy.py?rev=b6f544bc673b66adeac9e8315f8ef499c4956036#b6f544bc673b66adeac9e8315f8ef499c4956036" }
+source = { git = "https://github.com/parawanderer/FindMy.py?rev=bcb078761085c078eadac477da39a4c85b1f3a44#bcb078761085c078eadac477da39a4c85b1f3a44" }
dependencies = [
{ name = "aiohttp" },
{ name = "anisette" },
@@ -1032,7 +1032,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=b6f544bc673b66adeac9e8315f8ef499c4956036" },
+ { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=bcb078761085c078eadac477da39a4c85b1f3a44" },
{ name = "pycryptodome", specifier = "==3.22.0" },
{ name = "pyyaml", specifier = "==6.0.2" },
{ name = "pyzipper", specifier = "==0.4.0" },