Skip to content

Fix retry loop in main(): bounded retries, backoff, error classification - #7

Merged
zbuc merged 3 commits into
mainfrom
issue-3
Jul 22, 2026
Merged

Fix retry loop in main(): bounded retries, backoff, error classification#7
zbuc merged 3 commits into
mainfrom
issue-3

Conversation

@zbuc

@zbuc zbuc commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #3

What changed

The retries += 1 increment in main()'s retry loop was commented out, so the retries > 3 guard could never fire — any persistent error (stuck SQLite lock, revoked credentials, hard API failure) spun the loop forever with zero delay, restarting the full mailbox scan from page one each time.

  • Bounded retries, clean exit: re-enabled the counter; after MAX_RETRIES (3) transient failures, main() returns an anyhow::Error with context (nonzero exit) instead of panic!("Too many retries").
  • Error classification (is_transient()): walks the anyhow error chain and retries only transient failures — sqlx::Error::Database with SQLITE_BUSY (5) / SQLITE_LOCKED (6) primary result codes (extended codes like SQLITE_BUSY_SNAPSHOT keep the primary code in the low byte), sqlx::Error::PoolTimedOut, and google_gmail1::Error::Failure with HTTP 429 or 5xx. Everything else — auth failures, other 4xx, IO/network errors — fails fast on the first occurrence.
  • Exponential backoff with jitter (backoff_delay()): 1s doubling per attempt, capped at 32s, plus up to 1s of jitter (derived from the system clock's subsecond millis, avoiding a new dependency), applied via tokio::time::sleep before each retry.
  • Resume from last page: work() now takes a &mut Option<String> resume token that only advances after a page is fully processed, so a retry re-lists from the last successful next_page_token instead of page one.
  • Removed the stale retries dead code and the TODO/comments at the old src/main.rs:69-76.

Deliberately scoped to the outer retry loop, independent of the concurrency work in #5 (the pre-existing unused import: tokio::task warning belongs to that issue's commented-out code and is left untouched).

Verification

  • cargo build succeeds.
  • cargo clippy reports no new warnings (only the pre-existing unused import: tokio::task).
  • Not runtime-tested: the app requires Gmail OAuth credentials (credentials.json / tokencache.json) which are not available in this environment, so the retry/backoff behavior was verified by inspection and compilation only.

@zbuc

zbuc commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Pushed bdb2199 addressing two problems found in the retry loop:

1. Gmail 429/5xx were classified as non-retryable. With the pinned google-gmail1 7.0.0 / google-apis-common 8.0.0, doit() parses any non-success HTTP response body as JSON first and returns Error::BadRequest(value) when parsing succeeds. Gmail 429 rate-limit and 5xx errors carry Google's standard JSON error envelope, so they surfaced as BadRequest -- which fell into is_transient's _ => false arm, and a quota-tripped scan exited immediately with "giving up: error is not retryable". The Error::Failure arm the loop was built around only fires for non-JSON responses (e.g. an HTML 502 from a proxy). is_transient now also matches Error::BadRequest and reads the numeric error.code from the envelope (429 or 500-599 => transient), keeping the Failure arm for non-JSON responses.

2. The retry budget was lifetime, not consecutive. retries was incremented on every transient error and never reset, so the 4th transient error over the whole run -- even with hundreds of successfully processed pages between failures -- aborted with "giving up after 3 retries", defeating the resume-from-last-page design. The counter now resets whenever the failing attempt advanced resume_token since the previous attempt, so MAX_RETRIES bounds consecutive no-progress failures.

Also: added unit tests covering the BadRequest envelope classification (429/5xx transient, other 4xx and malformed envelopes not, cause found through an anyhow context chain), and removed the unused tokio::task import that was failing cargo clippy --all-targets -- -D warnings on this branch.

Verified: cargo build, cargo test (4 passed), and cargo clippy --all-targets -- -D warnings all pass.

zbuc added 2 commits July 21, 2026 22:55
The retries counter increment was commented out, so the retry guard could
never fire and any persistent error spun the loop forever with zero delay,
restarting the full mailbox scan from page one each time.

- Re-enable the retry counter and return an anyhow::Error after
  MAX_RETRIES attempts instead of panicking, for a clean nonzero exit.
- Classify errors before retrying: only transient failures are retried
  (SQLite SQLITE_BUSY/SQLITE_LOCKED including extended codes, sqlx pool
  timeouts, and Gmail API 429/5xx responses); auth failures, other 4xx,
  IO errors, etc. fail fast on first occurrence.
- Space retries with exponential backoff (1s doubling, capped at 32s)
  plus up to 1s of jitter via tokio::time::sleep.
- Have work() resume from the last fully processed page's
  next_page_token on retry instead of restarting from page one.

Closes #3
Two review findings on the retry loop:

1. Gmail 429/5xx responses were classified as non-retryable. With
   google-gmail1 7.0.0 / google-apis-common 8.0.0, the generated doit()
   code parses any non-success HTTP response body as JSON first and
   returns Error::BadRequest(value) when that succeeds. Gmail's 429
   rate-limit and 5xx errors carry Google's standard JSON error
   envelope, so they never reached the Error::Failure arm and fell into
   is_transient's catch-all `false`, aborting the run with "giving up:
   error is not retryable". is_transient now also matches
   Error::BadRequest and reads the numeric `error.code` field from the
   envelope (429 or 5xx => transient); the Failure arm remains for
   non-JSON responses such as an HTML 502 from an intermediary.

2. The retry counter was never reset, so MAX_RETRIES bounded *lifetime*
   transient errors instead of consecutive ones: on a long scan, the 4th
   intermittent SQLITE_BUSY or Gmail 5xx would kill the run even if
   thousands of pages succeeded between failures. The counter now resets
   whenever the failing attempt advanced the resume token (i.e. made
   progress) since the previous attempt.

Also adds unit tests for is_transient's Gmail error classification and
removes the unused `tokio::task` import that failed
`cargo clippy --all-targets -- -D warnings`.
@zbuc

zbuc commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Resolved the merge conflict with main by rebasing issue-3 onto origin/main (now includes the CI workflow merge from #10).

Conflict: src/main.rs around the retry-loop setup — main still had the old let retries = 0; stub with its TODO comment, while this branch replaces it with the mutable retry counter and resume_token. Resolved in favor of the PR's version, which is a strict replacement of the old code; no logic from main was lost.

No code changes beyond the conflict resolution — both commits were replayed as-is.

Verification (post-rebase):

  • cargo build passes
  • cargo test passes (4/4 tests)
  • cargo clippy --all-targets -- -D warnings passes

Force-pushed with --force-with-lease; PR is now mergeable.

Gmail delivers per-user rate limiting as HTTP 403 as well as 429: the
API error guide documents 403 rateLimitExceeded and
403 userRateLimitExceeded (usageLimits domain) with the instruction to
retry with exponential backoff. is_transient() only accepted 429/5xx
from the JSON envelope, so a burst of messages_get calls that tripped
the 250-quota-units/sec per-user limit surfaced a 403 BadRequest,
classified it as fatal, and aborted the whole mailbox scan -- exactly
the condition the retry machinery was built to survive.

Now the BadRequest arm also treats 403 as transient when
error.errors[*].reason is rateLimitExceeded/userRateLimitExceeded or
error.status is RESOURCE_EXHAUSTED, while other 403s (e.g.
insufficientPermissions, dailyLimitExceeded) still fail fast. Adds
unit tests for both the retryable and fatal 403 variants.
@zbuc

zbuc commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Reviewed the retry/backoff work on this branch and found one remaining gap in the transient-error classifier, fixed in 0fbaf82.

Found: is_transient() treated a google_gmail1::Error::BadRequest envelope as retryable only when error.code was 429 or 5xx, so every 403 failed fast. But the Gmail API delivers per-user rate limiting as HTTP 403 as well as 429 — Google's error guide documents 403: rateLimitExceeded and 403: userRateLimitExceeded (usageLimits domain) with the instruction to retry with exponential backoff. The scan loop calls messages_get back-to-back (5 quota units each against a 250 units/sec per-user limit), so on a fast connection it trips that limit, Gmail returns a 403 rate-limit envelope, and the run aborted with "giving up: error is not retryable" — exactly the condition the retry machinery in this PR was built to survive.

Fixed: the BadRequest arm now also treats 403 as transient when error.errors[*].reason is rateLimitExceeded/userRateLimitExceeded or error.status is RESOURCE_EXHAUSTED (via a new is_rate_limit_envelope helper). Genuinely fatal 403s (insufficientPermissions, dailyLimitExceeded, etc.) still fail fast. Added unit tests covering both the retryable and fatal 403 variants.

Verified (nothing else needed changing): cargo build, cargo test (7/7), and cargo clippy --all-targets -- -D warnings all pass; the sqlx & 0xff primary-code masking for SQLITE_BUSY/LOCKED is correct for sqlite's extended result codes; the BadRequest-for-JSON vs Failure-for-non-JSON split matches google-apis-common 8.0.0; the resume-token/retry-reset logic and backoff arithmetic trace through correctly; and mid-page retries are idempotent since each message commits in its own transaction guarded by seen_mails.

@zbuc
zbuc merged commit 97fa489 into main Jul 22, 2026
1 check passed
zbuc added a commit that referenced this pull request Jul 22, 2026
…d tests

Rebasing onto main replayed the concurrency rework over three PRs that
also changed main.rs; this restores their improvements on top of the
concurrent architecture:

- get_sender: single borrow-only case-insensitive pass over a header
  priority list (from, return-path), no clones, no panic on valueless
  headers (PR #6).
- Error classification: unify the per-call is_retryable_error with the
  outer loop's is_transient. is_transient walks the anyhow chain and
  recognizes SQLite busy/locked and PoolTimedOut; is_transient_gmail
  keeps the union of both sides' Gmail coverage (JSON envelope 429/5xx,
  403 rate-limit reasons and RESOURCE_EXHAUSTED, non-JSON Failure
  fallback, network-level errors) and remains the per-call retry
  predicate (PR #7).
- Outer retry loop: fail fast on non-transient errors (consulting the
  recovered writer error, since a dead writer surfaces to work() only
  as a channel-closed send error), resume from the last fully processed
  page via a resume token instead of re-listing from page one, and use
  capped exponential backoff with jitter (PR #7). The writer quiesce,
  fresh-writer respawn, and progress-based retry reset are unchanged.
- Tests: classification, cleanup_sender, get_sender, and SQLite
  counting suites (PRs #7, #13), with the DB tests adapted to the new
  signatures (mark_seen takes an id and reports newly-inserted,
  increment_sender_mails takes the cleaned sender) and run against the
  production init_schema DDL including the seen_mails unique index.
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.

Fix retry loop in main(): disabled counter causes infinite retries, no backoff, no error classification

1 participant