From 4e82b1ef41367abfe857a3b777836b12269d1dd4 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Tue, 7 Jul 2026 11:05:30 +0800 Subject: [PATCH] fix(login): retry transient poll errors instead of aborting the device flow A proxy 502 during a backend restart killed attended logins mid-poll. NotFound/InvalidArgument still fail fast; everything else retries until the device-code deadline, with a one-time notice. Co-Authored-By: Claude Fable 5 --- cmd/ninja/cmd/login.go | 22 +++++++++++++++++++++- cmd/ninja/cmd/login_test.go | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cmd/ninja/cmd/login.go b/cmd/ninja/cmd/login.go index d456e53..478a724 100644 --- a/cmd/ninja/cmd/login.go +++ b/cmd/ninja/cmd/login.go @@ -38,11 +38,19 @@ func newLoginCmd() *cobra.Command { fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode) interval := time.Duration(start.Msg.IntervalSeconds) * time.Second deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second) + warned := false for time.Now().Before(deadline) { time.Sleep(interval) poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode})) if err != nil { - return err + if !pollRetryable(err) { + return err + } + if !warned { + fmt.Printf("Temporary error while waiting for approval (will keep trying): %v\n", err) + warned = true + } + continue } switch poll.Msg.Status { case "pending": @@ -82,6 +90,18 @@ func newLoginCmd() *cobra.Command { return cmd } +// pollRetryable reports whether a PollDevice error is worth retrying until +// the device-code deadline. Transient transport failures (a proxy 502 while +// the backend restarts, timeouts) must not abort an attended login; NotFound +// and InvalidArgument mean the server can never approve this code. +func pollRetryable(err error) bool { + switch connect.CodeOf(err) { + case connect.CodeNotFound, connect.CodeInvalidArgument: + return false + } + return true +} + func newWhoamiCmd() *cobra.Command { return &cobra.Command{ Use: "whoami", diff --git a/cmd/ninja/cmd/login_test.go b/cmd/ninja/cmd/login_test.go index 3037b23..bff8d4c 100644 --- a/cmd/ninja/cmd/login_test.go +++ b/cmd/ninja/cmd/login_test.go @@ -1,8 +1,11 @@ package cmd import ( + "errors" "testing" + "connectrpc.com/connect" + "git.dev.alexdunmow.com/block/cli/internal/creds" ) @@ -25,3 +28,18 @@ func TestApplyPolledAccount_EmptyFallsThrough(t *testing.T) { t.Fatalf("hc = %+v, want untouched", hc) } } + +func TestPollRetryable(t *testing.T) { + if pollRetryable(connect.NewError(connect.CodeNotFound, errors.New("unknown device code"))) { + t.Error("NotFound must not be retried — the server no longer knows the code") + } + if pollRetryable(connect.NewError(connect.CodeInvalidArgument, errors.New("device_code is required"))) { + t.Error("InvalidArgument must not be retried") + } + if !pollRetryable(connect.NewError(connect.CodeUnavailable, errors.New("unavailable: 502 Bad Gateway"))) { + t.Error("Unavailable (proxy 502 during backend restart) must be retried") + } + if !pollRetryable(errors.New("plain network error")) { + t.Error("non-connect errors must be retried") + } +}