Skip to content

Commit e617543

Browse files
authored
feat: distinguish soft and hard errors in flow updates (#7)
* feat: distinguish soft and hard errors in flow updates - Introduce a Fallback field to FlowUpdate to distinguish soft errors (recoverable, trigger Device Code Flow) from hard errors (non-recoverable, surface to user). - Update error handling logic to use the Fallback field instead of matching error messages. - Add detailed documentation and comments clarifying error classes and flow control. - Implement tests to verify correct handling and routing of soft and hard errors using the Fallback field. Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com> * test: enhance error handling and test browser flow robustness - Improve error message rendering by using only the error string. - Add tests for SimpleManager browser flow, ensuring correct fallback signaling and update handling. Signed-off-by: appleboy <appleboy.tw@gmail.com> --------- Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com> Signed-off-by: appleboy <appleboy.tw@gmail.com>
1 parent b3b56ea commit e617543

5 files changed

Lines changed: 191 additions & 16 deletions

File tree

browser_flow.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,16 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*TokenStorage
9696
// performBrowserFlowWithUpdates runs the Authorization Code Flow with PKCE
9797
// and sends progress updates through the provided channel.
9898
//
99+
// Every exit path — success, soft error, and hard error — sends at least one
100+
// update before returning, so the Bubble Tea TUI always receives a quit signal
101+
// and never hangs.
102+
//
99103
// Returns:
100104
// - (storage, true, nil) on success
101-
// - (nil, false, nil) when openBrowser() fails — caller should fall back to Device Code Flow
102-
// - (nil, false, err) on a hard error (CSRF mismatch, token exchange failure, etc.)
105+
// - (nil, false, nil) on a soft error (browser unavailable, timeout) —
106+
// caller should fall back to Device Code Flow
107+
// - (nil, false, err) on a hard error (CSRF mismatch, token exchange
108+
// failure, OAuth server rejection, etc.)
103109
func performBrowserFlowWithUpdates(
104110
ctx context.Context,
105111
updates chan<- tui.FlowUpdate,
@@ -113,11 +119,19 @@ func performBrowserFlowWithUpdates(
113119

114120
state, err := generateState()
115121
if err != nil {
122+
updates <- tui.FlowUpdate{
123+
Type: tui.StepError,
124+
Message: fmt.Sprintf("Failed to generate state: %v", err),
125+
}
116126
return nil, false, fmt.Errorf("failed to generate state: %w", err)
117127
}
118128

119129
pkce, err := GeneratePKCE()
120130
if err != nil {
131+
updates <- tui.FlowUpdate{
132+
Type: tui.StepError,
133+
Message: fmt.Sprintf("Failed to generate PKCE parameters: %v", err),
134+
}
121135
return nil, false, fmt.Errorf("failed to generate PKCE: %w", err)
122136
}
123137

@@ -133,10 +147,11 @@ func performBrowserFlowWithUpdates(
133147
}
134148

135149
if err := openBrowser(ctx, authURL); err != nil {
136-
// Browser failed to open — signal the caller to fall back immediately.
150+
// Browser failed to open — soft error, signal the caller to fall back.
137151
updates <- tui.FlowUpdate{
138-
Type: tui.StepError,
139-
Message: fmt.Sprintf("Could not open browser: %v", err),
152+
Type: tui.StepError,
153+
Fallback: true,
154+
Message: fmt.Sprintf("Could not open browser: %v", err),
140155
}
141156
return nil, false, nil
142157
}
@@ -195,12 +210,20 @@ func performBrowserFlowWithUpdates(
195210
})
196211
if err != nil {
197212
if errors.Is(err, ErrCallbackTimeout) {
213+
// Soft error — fall back to Device Code Flow silently.
198214
updates <- tui.FlowUpdate{
199-
Type: tui.StepError,
200-
Message: "Browser authorization timed out",
215+
Type: tui.StepError,
216+
Fallback: true,
217+
Message: "Browser authorization timed out",
201218
}
202219
return nil, false, nil
203220
}
221+
// Hard error (CSRF mismatch, token exchange failure, OAuth rejection,
222+
// etc.) — surface it to the user.
223+
updates <- tui.FlowUpdate{
224+
Type: tui.StepError,
225+
Message: fmt.Sprintf("Authentication failed: %v", err),
226+
}
204227
return nil, false, fmt.Errorf("authentication failed: %w", err)
205228
}
206229

tui/browser_model.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,13 @@ func (m *BrowserModel) handleFlowUpdate(update FlowUpdate) tea.Cmd {
155155

156156
case StepError:
157157
m.err = fmt.Errorf("%s", update.Message)
158-
if update.Message == "Browser authorization timed out" ||
159-
update.Message == "Could not open browser: exit status 1" {
160-
// Fallback to device flow
158+
if update.Fallback {
159+
// Soft error — caller requested fall back to Device Code Flow.
161160
return func() tea.Msg {
162161
return successMsg{storage: nil, ok: false}
163162
}
164163
}
164+
// Hard error — surface it to the user.
165165
return func() tea.Msg {
166166
return errorMsg{err: m.err}
167167
}

tui/browser_view.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func renderBrowserComplete(m *BrowserModel) string {
7272
case m.err != nil:
7373
// Error state
7474
b.WriteString("\n")
75-
b.WriteString(RenderError(fmt.Sprintf("Authentication failed: %v", m.err)))
75+
b.WriteString(RenderError(m.err.Error()))
7676
b.WriteString("\n\n")
7777
case !m.ok:
7878
// Fallback to device flow

tui/manager_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"context"
45
"os"
56
"testing"
67
)
@@ -68,6 +69,41 @@ func TestSelectManager(t *testing.T) {
6869
})
6970
}
7071

72+
func TestSimpleManagerRunBrowserFlow(t *testing.T) {
73+
t.Run("Non-error updates do not trigger fallback", func(t *testing.T) {
74+
m := NewSimpleManager()
75+
perform := func(ctx context.Context, updates chan<- FlowUpdate) (*TokenStorage, bool, error) {
76+
updates <- FlowUpdate{Type: StepStart, Step: 1}
77+
updates <- FlowUpdate{Type: BrowserOpened}
78+
updates <- FlowUpdate{Type: StepStart, Step: 2}
79+
updates <- FlowUpdate{Type: CallbackReceived}
80+
return &TokenStorage{AccessToken: "test-token"}, true, nil
81+
}
82+
_, ok, err := m.RunBrowserFlow(context.Background(), perform)
83+
if err != nil {
84+
t.Fatalf("unexpected error: %v", err)
85+
}
86+
if !ok {
87+
t.Error("non-error updates must not trigger fallback: expected ok=true")
88+
}
89+
})
90+
91+
t.Run("perform returning ok=false is propagated as fallback signal", func(t *testing.T) {
92+
m := NewSimpleManager()
93+
perform := func(ctx context.Context, updates chan<- FlowUpdate) (*TokenStorage, bool, error) {
94+
updates <- FlowUpdate{Type: StepError, Message: "browser failed"}
95+
return nil, false, nil
96+
}
97+
_, ok, err := m.RunBrowserFlow(context.Background(), perform)
98+
if err != nil {
99+
t.Fatalf("unexpected error: %v", err)
100+
}
101+
if ok {
102+
t.Error("expected ok=false when perform signals fallback needed")
103+
}
104+
})
105+
}
106+
71107
func TestFlowUpdateHelpers(t *testing.T) {
72108
update := FlowUpdate{
73109
Type: StepStart,
@@ -105,6 +141,108 @@ func TestFlowUpdateHelpers(t *testing.T) {
105141
}
106142
}
107143

144+
func TestFlowUpdate_FallbackField(t *testing.T) {
145+
t.Run("Fallback defaults to false", func(t *testing.T) {
146+
update := FlowUpdate{Type: StepError, Message: "some hard error"}
147+
if update.Fallback {
148+
t.Error("Fallback should default to false for hard errors")
149+
}
150+
})
151+
152+
t.Run("Fallback can be set to true for soft errors", func(t *testing.T) {
153+
update := FlowUpdate{
154+
Type: StepError,
155+
Fallback: true,
156+
Message: "Browser authorization timed out",
157+
}
158+
if !update.Fallback {
159+
t.Error("Fallback should be true for soft (recoverable) errors")
160+
}
161+
})
162+
163+
t.Run("Fallback is ignored on non-error updates", func(t *testing.T) {
164+
update := FlowUpdate{Type: StepStart, Step: 1, Fallback: false}
165+
if update.Fallback {
166+
t.Error("Fallback should not be set on non-error updates")
167+
}
168+
})
169+
}
170+
171+
// TestBrowserModel_HandleFlowUpdate_StepError verifies that StepError updates
172+
// are routed to the correct internal message type based on the Fallback field,
173+
// so the Bubble Tea program always receives a quit signal (no hanging).
174+
func TestBrowserModel_HandleFlowUpdate_StepError(t *testing.T) {
175+
t.Run("hard error returns errorMsg", func(t *testing.T) {
176+
updates := make(chan FlowUpdate, 1)
177+
model := NewBrowserModel(updates, nil)
178+
179+
cmd := model.handleFlowUpdate(FlowUpdate{
180+
Type: StepError,
181+
Message: "Authentication failed: state_mismatch: state parameter mismatch",
182+
})
183+
184+
if cmd == nil {
185+
t.Fatal("expected non-nil tea.Cmd for hard error")
186+
}
187+
msg := cmd()
188+
if _, ok := msg.(errorMsg); !ok {
189+
t.Errorf("expected errorMsg for hard error, got %T", msg)
190+
}
191+
})
192+
193+
t.Run("soft error with Fallback=true returns successMsg{ok:false}", func(t *testing.T) {
194+
updates := make(chan FlowUpdate, 1)
195+
model := NewBrowserModel(updates, nil)
196+
197+
cmd := model.handleFlowUpdate(FlowUpdate{
198+
Type: StepError,
199+
Fallback: true,
200+
Message: "Browser authorization timed out",
201+
})
202+
203+
if cmd == nil {
204+
t.Fatal("expected non-nil tea.Cmd for soft error")
205+
}
206+
msg := cmd()
207+
sm, ok := msg.(successMsg)
208+
if !ok {
209+
t.Errorf("expected successMsg for soft error, got %T", msg)
210+
}
211+
if sm.ok {
212+
t.Error("successMsg.ok should be false for fallback (device flow signal)")
213+
}
214+
if sm.storage != nil {
215+
t.Error("successMsg.storage should be nil for fallback")
216+
}
217+
})
218+
219+
t.Run(
220+
"browser open failure with Fallback=true returns successMsg{ok:false}",
221+
func(t *testing.T) {
222+
updates := make(chan FlowUpdate, 1)
223+
model := NewBrowserModel(updates, nil)
224+
225+
cmd := model.handleFlowUpdate(FlowUpdate{
226+
Type: StepError,
227+
Fallback: true,
228+
Message: "Could not open browser: exec: no command",
229+
})
230+
231+
if cmd == nil {
232+
t.Fatal("expected non-nil tea.Cmd")
233+
}
234+
msg := cmd()
235+
sm, ok := msg.(successMsg)
236+
if !ok {
237+
t.Errorf("expected successMsg, got %T", msg)
238+
}
239+
if sm.ok {
240+
t.Error("successMsg.ok should be false for fallback")
241+
}
242+
},
243+
)
244+
}
245+
108246
func TestFlowUpdateTypeString(t *testing.T) {
109247
tests := []struct {
110248
updateType FlowUpdateType

tui/messages.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,27 @@ const (
1919
)
2020

2121
// FlowUpdate represents a progress update message from an OAuth flow.
22+
//
23+
// For StepError updates, the Fallback field distinguishes between two
24+
// classes of failure:
25+
//
26+
// - Fallback == true → soft error (browser unavailable, user timeout).
27+
// The caller should silently retry with the Device Code Flow.
28+
//
29+
// - Fallback == false → hard error (CSRF mismatch, token exchange failure,
30+
// OAuth server rejection, etc.).
31+
// The caller should surface the error to the user and exit.
2232
type FlowUpdate struct {
2333
Type FlowUpdateType
24-
Step int // Current step number (1-indexed)
25-
TotalSteps int // Total number of steps
26-
Message string // Human-readable message
27-
Progress float64 // Progress percentage (0.0 to 1.0)
28-
Data map[string]any // Additional data for specific update types
34+
Step int // Current step number (1-indexed)
35+
TotalSteps int // Total number of steps
36+
Message string // Human-readable message
37+
Progress float64 // Progress percentage (0.0 to 1.0)
38+
// Fallback is only meaningful when Type == StepError.
39+
// When true, the error is recoverable and the caller should fall back
40+
// to the Device Code Flow instead of reporting a failure.
41+
Fallback bool
42+
Data map[string]any // Additional data for specific update types
2943
}
3044

3145
// String returns a human-readable representation of the FlowUpdateType.

0 commit comments

Comments
 (0)