Closes#360. Adds the reactive throttle layer: when a provider rate-limits or locks us, the app backs off exponentially (with jitter) and degrades gracefully — pausing the offending activity (esp. background backfill) instead of hammering the server. The on-device perf drilldown (docs/perf/issue-125-*) proved that hammering a throttling server makes throttling worse and can trip a lasting lockout (e.g. Yahoo's ~1h lock); connection reuse (#357) is the proactive fix, this is the reactive one.
Rate limit: IMAP [THROTTLED] / "too many requests" / "too many simultaneous connections", RFC 5530 [LIMIT]/[UNAVAILABLE], SMTP "too many messages", HTTP 429/503.
Lockout: "account (temporarily) locked/suspended", "too many login attempts".
Deliberately conservative: a wrong password or the #390 "IMAP disabled" state is never misclassified (a false positive would silently stall an account for hours).
Per-account backoff window keyed by account id — one throttled account never stalls the others.
MailBackfiller skips an account inside its window, and stops paging one that throttles mid-slice (records the backoff, does not set moreWork, so BackfillWorker's slice-chaining loop stops rather than tight-looping against the server). Resumes on a later scheduled slice once the window elapses; resets on the next successful page.
MailSyncer (interactive/foreground sync) feeds the gate on a throttle failure but is never blocked by it — interactive priority over backfill.
Integration, not duplication: builds on the existing WorkManager retry (#403) and connection reuse (#357). The classifier also exposes classifyHttpStatus(...) + Retry-After honoring so the Outlook/Graph send path can wire in via the per-provider follow-up issues; this PR wires the IMAP backfill + foreground-sync paths.
Tests
ThrottleClassifierTest — positive + negative classification across IMAP/SMTP/HTTP, cause-chain + cyclic-chain, the #390 exclusion.
AccountThrottleGateTest — escalation, per-account isolation, reset-on-success, and the window clearing exactly when the backoff elapses (coroutines-test virtual time).
MailBackfillerTest / MailSyncerTest — throttle pauses/skips the account (no tight loop) and foreground sync records-without-blocking.
New source files carry SPDX headers; all logging is PII-free AppLog (accountLogRef + durations/counts).
Gate
Local fast gate green: assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification (0.84 floor held) + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt. No instrumented test touched (background logic; unit-tested); E2E via the CI matrix.
Notes / assumptions
Gate state is in-process (@Singleton); a process restart resets it — fine, WorkManager job backoff covers the cross-process case and a fresh process re-probes.
This is the umbrella #360's reactive layer. The per-provider concurrency caps (Gmail 15 / Yahoo 5 / iCloud 5 / Graph 4) and the Graph Retry-After send-path wiring remain for the per-provider sub-issues; the shared classifier/backoff/gate are ready for them.
## What & why
Closes #360. Adds the **reactive throttle layer**: when a provider rate-limits or locks us, the app backs off exponentially (with jitter) and **degrades gracefully** — pausing the offending activity (esp. background backfill) instead of hammering the server. The on-device perf drilldown (`docs/perf/issue-125-*`) proved that hammering a throttling server makes throttling worse and can trip a lasting lockout (e.g. Yahoo's ~1h lock); connection reuse (#357) is the *proactive* fix, this is the *reactive* one.
## Approach
**Classification signals** (`ThrottleClassifier`, message-text + HTTP-status):
- Rate limit: IMAP `[THROTTLED]` / "too many requests" / "too many simultaneous connections", RFC 5530 `[LIMIT]`/`[UNAVAILABLE]`, SMTP "too many messages", HTTP 429/503.
- Lockout: "account (temporarily) locked/suspended", "too many login attempts".
- Deliberately conservative: a wrong password or the #390 "IMAP disabled" state is **never** misclassified (a false positive would silently stall an account for hours).
**Backoff schedule** (`ThrottleBackoff`, pure/clock-free):
- Exponential `base * 2^(attempt-1)`, **equal jitter** → `[capped/2, capped]`, bounded max cap.
- Rate limit: 30s base, 15m cap. Lockout: 1h base (sized to Yahoo's lock), 4h cap.
- Honors a provider `Retry-After` as a lower bound.
**Degradation + per-account isolation** (`AccountThrottleGate`, `@Singleton`):
- Per-account backoff window keyed by account id — one throttled account never stalls the others.
- `MailBackfiller` skips an account inside its window, and stops paging one that throttles mid-slice (records the backoff, does **not** set `moreWork`, so `BackfillWorker`'s slice-chaining loop stops rather than tight-looping against the server). Resumes on a later scheduled slice once the window elapses; resets on the next successful page.
- `MailSyncer` (interactive/foreground sync) **feeds** the gate on a throttle failure but is **never blocked** by it — interactive priority over backfill.
**Integration, not duplication:** builds on the existing WorkManager retry (#403) and connection reuse (#357). The classifier also exposes `classifyHttpStatus(...)` + `Retry-After` honoring so the Outlook/Graph send path can wire in via the per-provider follow-up issues; this PR wires the IMAP backfill + foreground-sync paths.
## Tests
- `ThrottleClassifierTest` — positive + negative classification across IMAP/SMTP/HTTP, cause-chain + cyclic-chain, the #390 exclusion.
- `ThrottleBackoffTest` — exponential growth, jitter bounds, cap, lockout window, Retry-After floor.
- `AccountThrottleGateTest` — escalation, per-account isolation, reset-on-success, and the window clearing exactly when the backoff elapses (**coroutines-test virtual time**).
- `MailBackfillerTest` / `MailSyncerTest` — throttle pauses/skips the account (no tight loop) and foreground sync records-without-blocking.
- New source files carry SPDX headers; all logging is PII-free `AppLog` (accountLogRef + durations/counts).
## Gate
Local fast gate green: `assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` (0.84 floor held) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`. No instrumented test touched (background logic; unit-tested); E2E via the CI matrix.
## Notes / assumptions
- Gate state is in-process (`@Singleton`); a process restart resets it — fine, WorkManager job backoff covers the cross-process case and a fresh process re-probes.
- This is the umbrella #360's reactive layer. The per-provider concurrency caps (Gmail 15 / Yahoo 5 / iCloud 5 / Graph 4) and the Graph `Retry-After` send-path wiring remain for the per-provider sub-issues; the shared classifier/backoff/gate are ready for them.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What & why
Closes #360. Adds the reactive throttle layer: when a provider rate-limits or locks us, the app backs off exponentially (with jitter) and degrades gracefully — pausing the offending activity (esp. background backfill) instead of hammering the server. The on-device perf drilldown (
docs/perf/issue-125-*) proved that hammering a throttling server makes throttling worse and can trip a lasting lockout (e.g. Yahoo's ~1h lock); connection reuse (#357) is the proactive fix, this is the reactive one.Approach
Classification signals (
ThrottleClassifier, message-text + HTTP-status):[THROTTLED]/ "too many requests" / "too many simultaneous connections", RFC 5530[LIMIT]/[UNAVAILABLE], SMTP "too many messages", HTTP 429/503.Backoff schedule (
ThrottleBackoff, pure/clock-free):base * 2^(attempt-1), equal jitter →[capped/2, capped], bounded max cap.Retry-Afteras a lower bound.Degradation + per-account isolation (
AccountThrottleGate,@Singleton):MailBackfillerskips an account inside its window, and stops paging one that throttles mid-slice (records the backoff, does not setmoreWork, soBackfillWorker's slice-chaining loop stops rather than tight-looping against the server). Resumes on a later scheduled slice once the window elapses; resets on the next successful page.MailSyncer(interactive/foreground sync) feeds the gate on a throttle failure but is never blocked by it — interactive priority over backfill.Integration, not duplication: builds on the existing WorkManager retry (#403) and connection reuse (#357). The classifier also exposes
classifyHttpStatus(...)+Retry-Afterhonoring so the Outlook/Graph send path can wire in via the per-provider follow-up issues; this PR wires the IMAP backfill + foreground-sync paths.Tests
ThrottleClassifierTest— positive + negative classification across IMAP/SMTP/HTTP, cause-chain + cyclic-chain, the #390 exclusion.ThrottleBackoffTest— exponential growth, jitter bounds, cap, lockout window, Retry-After floor.AccountThrottleGateTest— escalation, per-account isolation, reset-on-success, and the window clearing exactly when the backoff elapses (coroutines-test virtual time).MailBackfillerTest/MailSyncerTest— throttle pauses/skips the account (no tight loop) and foreground sync records-without-blocking.AppLog(accountLogRef + durations/counts).Gate
Local fast gate green:
assembleDebug+testDebugUnitTest+jacocoTestCoverageVerification(0.84 floor held) +compileDebugAndroidTestKotlin+lintDebug+ktlintCheck+detekt. No instrumented test touched (background logic; unit-tested); E2E via the CI matrix.Notes / assumptions
@Singleton); a process restart resets it — fine, WorkManager job backoff covers the cross-process case and a fresh process re-probes.Retry-Aftersend-path wiring remain for the per-provider sub-issues; the shared classifier/backoff/gate are ready for them.@Mergifyio refresh
✅ Pull request refreshed
Merge Queue Status
2026-07-08 19:18 UTC· Rule:default· triggered by merge protections2026-07-08 19:45 UTC· atb63354b89a03d9358b5e05f87c69b51f60cf5eb4· mergeThis pull request spent 27 minutes 41 seconds in the queue, including 27 minutes 14 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed