From d059685d0bd5181aa0d9d0fe4c1949d2b81989e2 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Fri, 11 Sep 2026 12:52:51 +0200 Subject: [PATCH] feat(pickups): hint the wait on a run_cooldown refusal Core (PR #35) refuses a manual pickup run within a minute of the last dispatch with 429 run_cooldown, because each run is one POP3 login at the customer's provider and providers count those. `openemail pickups run` printed "run_cooldown (HTTP 429)" with the retryAfter as a bare extra. Add the errorHint case naming the wait in seconds, and note the status on RunPickup. Co-Authored-By: Claude Fable 5.1 --- internal/cli/errorhint_test.go | 12 ++++++++++++ internal/cli/root.go | 10 ++++++++++ internal/coreapi/pickups.go | 5 ++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/cli/errorhint_test.go b/internal/cli/errorhint_test.go index d5619d7..46e3447 100644 --- a/internal/cli/errorhint_test.go +++ b/internal/cli/errorhint_test.go @@ -59,6 +59,18 @@ func TestErrorHint(t *testing.T) { ae: &coreapi.APIError{Status: 409, Code: "not_embedded", Extra: map[string]any{"semanticFloor": float64(1735689600)}}, want: "--semantic-floor all", }, + { + // The wait rides the envelope; without it the hint still says what + // happened, since "run_cooldown (HTTP 429)" alone reads as a fault. + name: "run_cooldown names the wait", + ae: &coreapi.APIError{Status: 429, Code: "run_cooldown", Extra: map[string]any{"retryAfter": float64(41)}}, + want: "try again in 41 s", + }, + { + name: "run_cooldown without a wait still hints", + ae: &coreapi.APIError{Status: 429, Code: "run_cooldown"}, + want: "less than a minute ago", + }, { // The end of a key's life, and the one failure the platform // manufactures on its own schedule: a CLI key that lapses from disuse diff --git a/internal/cli/root.go b/internal/cli/root.go index ccc9e02..688f1b3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -240,6 +240,16 @@ func errorHint(ae *coreapi.APIError) string { return fmt.Sprintf("this message is older than the indexed window (indexed from %s) — reach further back with: openemail mailboxes update --semantic-floor all", fmtEpoch(int64(floor))) } return "this message has not been embedded — it may predate the indexed window, or the backfill may still be running" + case "run_cooldown": + // A pickup run is one POP3 login at the customer's provider, and + // providers count those, so core dispatches a source at most once a + // minute, by schedule or by hand. A run already in flight collects + // whatever is new, so there is nothing to gain by waiting it out at + // the keyboard; `retryAfter` rides the envelope in whole seconds. + if wait, ok := ae.Extra["retryAfter"].(float64); ok && wait > 0 { + return fmt.Sprintf("this source was dispatched less than a minute ago, and a run in flight already collects whatever is new; try again in %d s", int(wait)) + } + return "this source was dispatched less than a minute ago, and a run in flight already collects whatever is new; try again shortly" case "verification_unavailable": // A resolver outage, not the customer's DNS. Retrying is the whole fix. return "DNS could not be queried just now — nothing was changed; try again shortly" diff --git a/internal/coreapi/pickups.go b/internal/coreapi/pickups.go index e48e46e..ac88d26 100644 --- a/internal/coreapi/pickups.go +++ b/internal/coreapi/pickups.go @@ -128,7 +128,10 @@ func (c *Client) DeletePickup(ctx context.Context, mailboxID, pickupID string) e } // RunPickup schedules an out-of-band run (202 scheduled). 409 disabled if the -// source is disabled. +// source is disabled; 429 run_cooldown if the source was dispatched (by +// schedule or by hand) under a minute ago, with `retryAfter` seconds in the +// envelope. A run already in flight absorbs the trigger, so the cooldown +// bounds the run AFTER the run, not this one. func (c *Client) RunPickup(ctx context.Context, mailboxID, pickupID string) (string, error) { var out struct { Status string `json:"status"`