Skip to content

inbox: widening poll backoff on an unreachable server, without deactivating the mailbox - #254

Open
Ahmustufa wants to merge 4 commits into
mainfrom
feature/inbox-poll-backoff
Open

Ahmustufa wants to merge 4 commits into
mainfrom
feature/inbox-poll-backoff

Conversation

@Ahmustufa

Copy link
Copy Markdown
Contributor

PR 2 of P1.8 (PR 1 was #253). The last sub-item, and the one whose brief was wrong.

The problem is not what the parity plan says it is

The plan says "widening backoff … without deactivating the mailbox", implying something deactivates it. Nothing does — re-verified here, not taken on trust: mailboxes.status is written by exactly one query, reached from one function, reachable only from Pause/Resume, only ever with paused/active.

So an hour-long outage does not cost the connection. It costs ~60 dial+auth attempts and ~20 dead-letter rows per mailbox per hour — and that reconnect hammering is itself what provokes the per-IP sign-in throttles the fleet architecture exists to avoid, while burying real failures in dead-letter noise.

The trap, avoided: status = 'error' is a documented valid value, and writing it would have stopped sending too, because MailboxExists and ListActiveMailboxes both gate on active. The backoff therefore lives in two new columns read by one query. Verified: RecordInboxPollFailure writes only inbox_poll_failures and inbox_poll_retry_after, workspace-pinned — it is structurally incapable of touching status.

Four signals, three answers

ClassifyConnectFailure never inspects error text — every branch is a sentinel, a type, or a status code.

Signal Class Schedule
Refused / reset / EOF / DNS failure transport widening ladder
Accepts-then-stalls (deadline, net.Error.Timeout) transport widening ladder
TLS handshake failure transport widening ladder
Provider 429 / 5xx transport widening ladder
Wrong password, revoked token, no usable mechanism, 401/403 auth straight to the cap + SkipRetry
SSRF guard refusal policy straight to the cap
Our own context.Canceled aborted nothing recorded
Non-dial failure (cursor write, job fetch) — nothing recorded

Auth goes to the cap immediately, and the argument is the same one already made one level down in this codebase: imapauth.go caps itself at two attempts per connection because "walking every mechanism would turn one wrong password into four rejected sign-ins per poll, every three minutes, which is how a provider decides to lock an account." A retry cannot fix a wrong password, and each one is another rejected sign-in.

Transport is read before the auth sentinel, deliberately: a connection dropping mid-LOGIN is wrapped as auth but is transport in substance, and reading the sentinel first would park a mailbox on a flaky network at the hour cap as though its password were wrong. A test pins that ordering.

The ladder

transport/unknown:  3m → 6 → 12 → 24 → 48 → 60m (clamped)
auth/policy:        [60m]

The first rung equals inboxSweepInterval, so one failed poll changes nothing — a blip costs a healthy mailbox no latency. An hour's outage drops from ~20 fan-outs to 5.

The cap is the requirement, not the rungs. An uncapped exponential reaches a day, then a week, and a mailbox nobody checks stops detecting replies for good — a deactivation with extra steps. Capped, it is still polled hourly and recovers on its own.

What a backed-off mailbox can still do

Everything except be fanned out this tick. It still sends — sequence steps, warm-up, manual replies. status stays active. It rejoins automatically when retry_after passes, and pause → resume clears the counter, so an operator who fixes a password doesn't wait out the hour. (That reset was added beyond the sketch; without it there was no prompt manual recovery.)

Two details worth the review time

The delay is computed on the database clock — now() + make_interval(...) — because ListActiveMailboxes compares against the database clock. Computing it host-side would have let the exact host↔DB skew that bit this project twice decide whether a mailbox is due. No time.Now() appears in any new assertion; every time test measures the delta the database computed.

The ladder crosses the wire as seconds and is validated on both sides. An empty ladder would leave retry_after NULL and silently disable the whole feature.

coreapi.Client was not widened — PollBackoffCore is consumer-defined and resolved by type assertion once at wiring time, matching the neighbouring WarmupEvidenceClient check. Per-message assertion is what let a missing capability degrade in silence before.

Scope extended once, deliberately

graphinbox.go reported every non-2xx as fmt.Errorf("unexpected status %d"), so a revoked M365 token would have classified as unknown and taken the gentle ladder instead of the cap — half the feature, invisibly. Now carried as *APIError, same as the send path. Gmail needed nothing; its client already returns *googleapi.Error and the wrapper %ws it, which was verified rather than assumed.

Verification

Gate Result
sqlc generate no drift
go build / go vet / go vet -tags=integration clean
gofmt -l internal cmd empty
golangci-lint 2.12.2 (pinned, cache cleaned) 0 issues
TZ=UTC go test -race -count=1 -p 4 -tags=integration ./... 102 packages, 0 FAIL, 0 races

Rebased onto b5d2571 (#242) and re-verified; the migration remains the newest, so no version collision. Guarantee-carrying tests run explicitly: TestPollBackoffSuppressesSchedulingButNeverEligibility, TestAnUnreachableServerBacksOffWithoutDeactivatingTheMailbox, TestARejectedCredentialCapsImmediatelyAndStillSends, TestPollBackoffMigrationRollsBack — all PASS.

Every guard was removed and watched to fail, then restored. The classifier is driven through the real IMAP client against PR 1's in-process server — no mocked reader; a new stopListening closes the listener so the next dial gets a real ECONNREFUSED from the kernel. Migration up→down→up verified, including that a pre-existing mailbox lands eligible after the up, so an upgrade does not silently stop polling every mailbox in the install.

Flagged, not fixed

  1. Pre-existing tenancy gap, found in passing and left alone: MailboxExists is WHERE id = $1 AND status = 'active' with no workspace_id filter. It is an existing recorded exception and was leaned on as the "still sends" gate, not changed. Independently verified. Worth its own ticket.
  2. GetInboxPollJob failures are not backed off. For Gmail/M365 that call performs the OAuth refresh in the control plane, so a revoked refresh token still means a rejected token request on every sweep — real hammering this does not stop. Fixing it properly means classifying in the control plane and returning a typed error.
  3. ConnectFailureAborted has a bounded blind spot. Cancellation is only observable on the legs taking a context; once a session is up, a cancel surfaces as an i/o timeout and classifies as transport — one rung, cleared by the next success. Found because the first version of that test failed; the test was narrowed and the limitation written into the doc rather than the test deleted.
  4. Nothing surfaces the failure count or retry time in the API or UI. Counters are persisted and the classification is logged (ids and class only — never error text or anything derived from a credential), so a genuine failure is visible to an operator with database or log access, not to a user. That is the obvious PR 3, and it pairs with the permanently-0 mailbox-error tile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t

Ahmustufa and others added 4 commits September 24, 2026 16:21
An unreachable mail server costs ~60 dial+auth attempts and ~20 dead-letter
rows per mailbox per hour today: inbox:sweep re-fans the poll out every 3
minutes forever, because nothing in the worker or coreapi writes
mailboxes.status and the poll failure is simply retried and dropped.

Adds mailboxes.inbox_poll_failures / inbox_poll_retry_after, read by exactly
one query — ListActiveMailboxes, the poll fan-out. MailboxExists and
ReserveMailboxSendSlot are untouched, so a backed-off mailbox still SENDS and
status stays 'active'; writing status='error' would gate both and cost the
user their mailbox, which is what "without deactivating the mailbox" forbids.

RecordInboxPollFailure takes the schedule as a parameter array and indexes it
by the new failure count, clamped to the last rung. The ladder therefore lives
in Go and the cap is the clamp; the delay is added to the DATABASE clock,
because ListActiveMailboxes compares it against the database clock. Clearing
is folded into both cursor writes and into UpdateMailboxStatus, so a
successful poll costs no extra round trip and pause→resume is the manual reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t
A refused connection, a black-holed one, a wrong password and an SSRF refusal
are four different signals, and the inbox poller needs to treat them
differently: the first two fix themselves, the last two do not, and repeating
a rejected sign-in is how a provider decides to lock an account.

ClassifyConnectFailure reads sentinels, types and status codes only — never
error text — and reads TRANSPORT EVIDENCE BEFORE the new ErrAuthRejected
sentinel, so a connection that drops mid-LOGIN is not mistaken for a bad
password. Cancellation is separated out so a shutting-down worker does not
record failures against healthy mailboxes.

Driven through the real IMAP client against PR 1's in-process server, which
gains one capability: stopListening, a listener closed under the transport
seam so the next dial gets a real ECONNREFUSED from the kernel rather than a
stubbed reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t
An hour-long outage cost ~60 dial+auth attempts and ~20 dead-letter rows per
mailbox, because a failed poll was simply retried and re-fanned-out every
three minutes forever. That reconnect hammering is what provokes the per-IP
sign-in throttles the fleet exists to avoid, and it buries real failures.

The poller now classifies the failure and records it through a narrow,
consumer-defined coreapi capability (PollBackoffCore) carried by BOTH clients,
so a fleet host backs off exactly as a single-process install does:

  transport / unknown  3m -> 6 -> 12 -> 24 -> 48 -> 60m, capped
  auth / SSRF refusal  straight to the 60m cap, and asynq.SkipRetry
  cancelled            nothing recorded; our shutdown is not the server's fault
  our own failure      nothing recorded; the dial legs are the only ones counted

Auth is separated because a retry there is not free: it is another rejected
sign-in, which is how a provider locks an account — the same reasoning
authenticateIMAP already applies one level down. Transport evidence is read
BEFORE the auth sentinel, so a connection that drops mid-LOGIN is not parked on
the cap as though its password were wrong.

The cap is the requirement, not the rungs: a backed-off mailbox is still
polled, just hourly, and recovers on its own because the successful poll's
cursor write clears the counter in the same statement. It is suppressed from
the POLL fan-out and nothing else — status is never written, MailboxExists is
untouched, and it still sends throughout. A failure that cannot be recorded
fails OPEN, back to today's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t
…n them

The delta, junk-list and $value legs reported every non-2xx as
fmt.Errorf("graph: ...: unexpected status %d"). That is the exact failure
*APIError was introduced for on the SEND path: a status recoverable only by
parsing English.

It now matters here too. The poll backoff wants opposite handling for a Graph
401 (a revoked token — straight to the hour cap, because retrying is another
rejected sign-in) and a 429/503 ("come back later" — the widening ladder), and
through a sentence both classified as unknown and took the gentle ladder.

The test drives graphDelta against a real httptest server rather than through
Fetch, because Fetch host-pins the cursor to graph.microsoft.com before dialing
it (security invariant 13) and must keep being unable to reach a test host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t
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.

1 participant