Repository navigation
Conversation
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
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.
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.statusis written by exactly one query, reached from one function, reachable only fromPause/Resume, only ever withpaused/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, becauseMailboxExistsandListActiveMailboxesboth gate onactive. The backoff therefore lives in two new columns read by one query. Verified:RecordInboxPollFailurewrites onlyinbox_poll_failuresandinbox_poll_retry_after, workspace-pinned — it is structurally incapable of touchingstatus.Four signals, three answers
ClassifyConnectFailurenever inspects error text — every branch is a sentinel, a type, or a status code.net.Error.Timeout)SkipRetrycontext.CanceledAuth goes to the cap immediately, and the argument is the same one already made one level down in this codebase:
imapauth.gocaps 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-
LOGINis 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
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.
statusstaysactive. It rejoins automatically whenretry_afterpasses, 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(...)— becauseListActiveMailboxescompares 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. Notime.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_afterNULL and silently disable the whole feature.coreapi.Clientwas not widened —PollBackoffCoreis consumer-defined and resolved by type assertion once at wiring time, matching the neighbouringWarmupEvidenceClientcheck. Per-message assertion is what let a missing capability degrade in silence before.Scope extended once, deliberately
graphinbox.goreported every non-2xx asfmt.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.Errorand the wrapper%ws it, which was verified rather than assumed.Verification
sqlc generatego build/go vet/go vet -tags=integrationgofmt -l internal cmdTZ=UTC go test -race -count=1 -p 4 -tags=integration ./...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
stopListeningcloses the listener so the next dial gets a realECONNREFUSEDfrom 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
MailboxExistsisWHERE id = $1 AND status = 'active'with noworkspace_idfilter. It is an existing recorded exception and was leaned on as the "still sends" gate, not changed. Independently verified. Worth its own ticket.GetInboxPollJobfailures 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.ConnectFailureAbortedhas 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.🤖 Generated with Claude Code
https://claude.ai/code/session_01VECELVAKe8Xcp7GH9wGR5t