Skip to content

Commit b36ab6f

Browse files
authored
Retry on partial live branch connections responses instead of showing partial results (#1275)
1 parent d966002 commit b36ab6f

9 files changed

Lines changed: 345 additions & 62 deletions

File tree

internal/connections/client.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,21 +192,39 @@ func (s *Client) List(ctx context.Context, sort SortMode) (ConnectionList, error
192192
}
193193

194194
deadline := time.Now().Add(s.retryBudget)
195+
var partial ConnectionList
196+
havePartial := false
195197
for {
196198
list, retryAfter, err := s.tryList(ctx, sort)
197-
if err == nil {
199+
switch {
200+
case err == nil && !list.HasUnreachableInstance():
198201
return list, nil
199-
}
200-
if isTimeoutError(err) && ctx.Err() != nil {
202+
case err == nil:
203+
// 200 OK, but the server flagged one or more instances unreachable
204+
// (a partial fan-out failure). These are usually momentary under
205+
// load and clear within a poll or two, so retry within the budget.
206+
// Retry locally; the response carries no Retry-After hint.
207+
partial, havePartial = list, true
208+
retryAfter = 0
209+
case isTimeoutError(err) && ctx.Err() != nil:
201210
if callerCtx.Err() != nil {
202211
return ConnectionList{}, fmt.Errorf("list connections: %w", callerCtx.Err())
203212
}
213+
// A later attempt timed out, but an earlier one already returned a
214+
// usable partial. Surface that (matching the wait path) rather than a
215+
// failed-refresh error.
216+
if havePartial {
217+
return partial, nil
218+
}
204219
return ConnectionList{}, fmt.Errorf("list connections: request timed out after %s, please retry", s.cfg.RequestTimeout)
205-
}
206-
if !errors.Is(err, errListWarming) {
220+
case !errors.Is(err, errListWarming):
207221
return ConnectionList{}, err
208222
}
223+
209224
if s.retryBudget <= 0 {
225+
if havePartial {
226+
return partial, nil
227+
}
210228
return ConnectionList{}, errListWarmingExhausted
211229
}
212230

@@ -215,12 +233,21 @@ func (s *Client) List(ctx context.Context, sort SortMode) (ConnectionList, error
215233
delay = s.retryDelay()
216234
}
217235
if time.Now().Add(delay).After(deadline) {
236+
if havePartial {
237+
return partial, nil
238+
}
218239
return ConnectionList{}, errListWarmingExhausted
219240
}
220241

221242
select {
222243
case <-time.After(delay):
223244
case <-ctx.Done():
245+
if callerCtx.Err() != nil {
246+
return ConnectionList{}, fmt.Errorf("list connections: %w", callerCtx.Err())
247+
}
248+
if havePartial {
249+
return partial, nil
250+
}
224251
return ConnectionList{}, fmt.Errorf("list connections: %w", ctx.Err())
225252
}
226253
}

internal/connections/client_test.go

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const sampleListResponse = `{
2020
"captured_at": "2026-04-29T12:34:56.789Z",
2121
"instances": [
2222
{"id": "primary", "role": "primary", "error": null},
23-
{"id": "replica-1", "role": "replica", "error": "timeout after 2s"}
23+
{"id": "replica-1", "role": "replica", "error": null}
2424
],
2525
"data": [
2626
{
@@ -180,8 +180,8 @@ func TestClient_ListDecodesConnectionList(t *testing.T) {
180180
if list.Instances[0] != (InstanceMeta{ID: "primary", Role: "primary"}) {
181181
t.Errorf("Instances[0] = %+v, want primary/primary/(no error)", list.Instances[0])
182182
}
183-
if list.Instances[1] != (InstanceMeta{ID: "replica-1", Role: "replica", Error: "timeout after 2s"}) {
184-
t.Errorf("Instances[1] = %+v, want replica-1/replica/timeout", list.Instances[1])
183+
if list.Instances[1] != (InstanceMeta{ID: "replica-1", Role: "replica"}) {
184+
t.Errorf("Instances[1] = %+v, want replica-1/replica/(no error)", list.Instances[1])
185185
}
186186

187187
if first.InstanceRole != "primary" {
@@ -526,6 +526,144 @@ func TestClientListPartialResponseFriendlyMessage(t *testing.T) {
526526
}
527527
}
528528

529+
// partialInstanceListResponse models a 200 OK that the server returns while a
530+
// fan-out to one instance failed: the primary is flagged unreachable while the
531+
// replica reports cleanly. The error string mirrors a real captured trace.
532+
const partialInstanceListResponse = `{
533+
"type": "list",
534+
"captured_at": "2026-04-29T12:34:56.789Z",
535+
"instances": [
536+
{"id": "primary", "role": "primary", "error": "remote service unavailable"},
537+
{"id": "replica-1", "role": "replica", "error": null}
538+
],
539+
"data": []
540+
}`
541+
542+
func TestClient_ListRetriesPartialInstanceErrorThenReturnsClean(t *testing.T) {
543+
var calls atomic.Int32
544+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
545+
w.Header().Set("Content-Type", "application/json")
546+
if calls.Add(1) == 1 {
547+
_, _ = io.WriteString(w, partialInstanceListResponse)
548+
return
549+
}
550+
_, _ = io.WriteString(w, sampleListResponse)
551+
}))
552+
defer srv.Close()
553+
554+
c := newClientWithTimings(t, srv.URL, 500*time.Millisecond, 10*time.Millisecond, 0)
555+
list, err := c.List(context.Background(), SortByTransactionStart)
556+
if err != nil {
557+
t.Fatalf("List: %v", err)
558+
}
559+
if got := calls.Load(); got != 2 {
560+
t.Fatalf("calls = %d, want 2 (retried past the partial-instance failure)", got)
561+
}
562+
for _, inst := range list.Instances {
563+
if inst.Error != "" {
564+
t.Fatalf("returned list still reports unreachable instance %q (%q), want a clean retry result", inst.ID, inst.Error)
565+
}
566+
}
567+
}
568+
569+
func TestClient_ListReturnsPartialAfterRetryBudgetExhausted(t *testing.T) {
570+
var calls atomic.Int32
571+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
572+
calls.Add(1)
573+
w.Header().Set("Content-Type", "application/json")
574+
_, _ = io.WriteString(w, partialInstanceListResponse)
575+
}))
576+
defer srv.Close()
577+
578+
c := newClientWithTimings(t, srv.URL, 40*time.Millisecond, 10*time.Millisecond, 0)
579+
start := time.Now()
580+
list, err := c.List(context.Background(), SortByTransactionStart)
581+
elapsed := time.Since(start)
582+
if err != nil {
583+
t.Fatalf("List: want partial list, got error %v", err)
584+
}
585+
if calls.Load() < 2 {
586+
t.Fatalf("calls = %d, want at least 2 (retried before giving up)", calls.Load())
587+
}
588+
if elapsed > time.Second {
589+
t.Fatalf("List took %v, want retry budget to expire promptly", elapsed)
590+
}
591+
// A persistent partial failure still returns useful data: the reachable
592+
// instances plus the per-instance error that drives the unreachable banner.
593+
var found bool
594+
for _, inst := range list.Instances {
595+
if inst.ID == "primary" && inst.Error == "remote service unavailable" {
596+
found = true
597+
}
598+
}
599+
if !found {
600+
t.Fatalf("partial list lost the instance error; instances = %+v", list.Instances)
601+
}
602+
}
603+
604+
func TestClient_ListReturnsSavedPartialWhenLaterAttemptTimesOut(t *testing.T) {
605+
var calls atomic.Int32
606+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
607+
if calls.Add(1) == 1 {
608+
w.Header().Set("Content-Type", "application/json")
609+
_, _ = io.WriteString(w, partialInstanceListResponse)
610+
return
611+
}
612+
// Hang a later attempt until the request timeout fires; respect the
613+
// request context so server shutdown doesn't block on this handler.
614+
select {
615+
case <-r.Context().Done():
616+
case <-time.After(5 * time.Second):
617+
}
618+
}))
619+
defer srv.Close()
620+
621+
// Budget allows a retry; the per-request timeout fires inside the hung second
622+
// attempt — the timeout-while-holding-a-saved-partial path. It must return
623+
// the partial (matching the wait path), not a failed-refresh error.
624+
c := newClientWithTimings(t, srv.URL, 2*time.Second, 10*time.Millisecond, 0)
625+
c.cfg.RequestTimeout = 300 * time.Millisecond
626+
627+
list, err := c.List(context.Background(), SortByTransactionStart)
628+
if err != nil {
629+
t.Fatalf("List: want the saved partial, got error %v", err)
630+
}
631+
if calls.Load() < 2 {
632+
t.Fatalf("calls = %d, want at least 2 (timed out on a later attempt)", calls.Load())
633+
}
634+
var found bool
635+
for _, inst := range list.Instances {
636+
if inst.ID == "primary" && inst.Error == "remote service unavailable" {
637+
found = true
638+
}
639+
}
640+
if !found {
641+
t.Fatalf("expected the saved partial after the timeout; instances = %+v", list.Instances)
642+
}
643+
}
644+
645+
func TestClient_ListDoesNotRetryPartialWhenBudgetDisabled(t *testing.T) {
646+
var calls atomic.Int32
647+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
648+
calls.Add(1)
649+
w.Header().Set("Content-Type", "application/json")
650+
_, _ = io.WriteString(w, partialInstanceListResponse)
651+
}))
652+
defer srv.Close()
653+
654+
c := newClientWithTimings(t, srv.URL, 0, 10*time.Millisecond, 0)
655+
list, err := c.List(context.Background(), SortByTransactionStart)
656+
if err != nil {
657+
t.Fatalf("List: %v", err)
658+
}
659+
if got := calls.Load(); got != 1 {
660+
t.Fatalf("calls = %d, want 1 (no retry when budget disabled)", got)
661+
}
662+
if got := list.Instances[0].Error; got != "remote service unavailable" {
663+
t.Fatalf("Instances[0].Error = %q, want decoded instance error", got)
664+
}
665+
}
666+
529667
func TestClient_ListRetryWaitHonorsContextCancellation(t *testing.T) {
530668
var calls atomic.Int32
531669
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -555,6 +693,36 @@ func TestClient_ListRetryWaitHonorsContextCancellation(t *testing.T) {
555693
}
556694
}
557695

696+
func TestClient_ListPartialRetryWaitHonorsContextCancellation(t *testing.T) {
697+
var calls atomic.Int32
698+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
699+
calls.Add(1)
700+
w.Header().Set("Content-Type", "application/json")
701+
_, _ = io.WriteString(w, partialInstanceListResponse)
702+
}))
703+
defer srv.Close()
704+
705+
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
706+
defer cancel()
707+
708+
c := newClientWithTimings(t, srv.URL, 500*time.Millisecond, 250*time.Millisecond, 0)
709+
start := time.Now()
710+
_, err := c.List(ctx, SortByTransactionStart)
711+
elapsed := time.Since(start)
712+
if err == nil {
713+
t.Fatal("List: want context error, got nil")
714+
}
715+
if !errors.Is(err, context.DeadlineExceeded) {
716+
t.Fatalf("err = %v, want errors.Is(err, context.DeadlineExceeded)", err)
717+
}
718+
if got := calls.Load(); got != 1 {
719+
t.Fatalf("calls = %d, want 1 because context expired during partial retry wait", got)
720+
}
721+
if elapsed > time.Second {
722+
t.Fatalf("List took %v, want context cancellation to interrupt partial retry wait", elapsed)
723+
}
724+
}
725+
558726
func TestClient_ListHonorsRetryAfterHeader(t *testing.T) {
559727
var calls atomic.Int32
560728
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

internal/connections/connection_list.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ type ConnectionList struct {
4343
Topology *Topology `json:"topology,omitempty"`
4444
}
4545

46+
// HasUnreachableInstance reports whether the server flagged any instance as
47+
// unreachable in the list response. These partial fan-out failures drive the
48+
// "N of M instances unreachable" banner.
49+
func (l ConnectionList) HasUnreachableInstance() bool {
50+
for _, inst := range l.Instances {
51+
if inst.Error != "" {
52+
return true
53+
}
54+
}
55+
return false
56+
}
57+
4658
type Connection struct {
4759
PID int `json:"pid"`
4860
Instance string `json:"instance"`

internal/connections/tui/detail.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,9 @@ func blockerTreeLabel(row blockerRow, selected bool) string {
434434
// consistent across views.
435435
return selectedRowStyle.Render("▶ " + label)
436436
}
437-
return label
437+
// Reserve the caret's width on unselected rows so moving the selection
438+
// doesn't shift the row text, matching the table view's fixed-width marker.
439+
return " " + label
438440
}
439441

440442
func wrapLines(text string, width int) []string {

internal/connections/tui/detail_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,3 +688,20 @@ func TestDetailHelpHidesRefreshWhilePaused(t *testing.T) {
688688
c.Assert(footer, qt.Not(qt.Contains), "r refresh")
689689
c.Assert(footer, qt.Contains, "space resume")
690690
}
691+
692+
func TestBlockerTreeLabelReservesCaretWidth(t *testing.T) {
693+
c := qt.New(t)
694+
row := blockerRow{PID: 123, Present: false}
695+
696+
// The selection caret ("▶ ") must not shift the row text: an unselected
697+
// row reserves the caret's width so the label sits at the same column
698+
// whether or not it is selected (matching the table view's fixed marker).
699+
unselected := blockerTreeLabel(row, false)
700+
c.Assert(unselected, qt.Equals, " "+blockerLabel(row))
701+
702+
// Selected rows use the caret + a space — the same two display columns as
703+
// the unselected padding, so the label text never shifts between states.
704+
selectedInner := ansi.Strip(blockerTreeLabel(row, true))
705+
c.Assert(strings.HasPrefix(selectedInner, "▶ "), qt.IsTrue)
706+
c.Assert(strings.TrimPrefix(selectedInner, "▶ "), qt.Equals, strings.TrimPrefix(unselected, " "))
707+
}

internal/connections/tui/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
343343
m.consecutiveErrors++
344344
return m, nil
345345
}
346+
// A degraded result reached us after the client exhausted its retries on
347+
// a partial-instance failure. Don't swap it into the live view: hold the
348+
// last good frame and let the staleness dot and captured-age surface it,
349+
// rather than flashing the "N unreachable" banner over good data. First
350+
// load has no good frame to hold, so fall through and show what we have.
351+
if m.hasList && msg.list.HasUnreachableInstance() {
352+
m.consecutiveErrors++
353+
return m, nil
354+
}
346355
m.consecutiveErrors = 0
347356
m.initialAccessDenied = false
348357
cursor := m.samples.Push(msg.list)

internal/connections/tui/model_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,49 @@ func TestModelKeepsLastListWhenRefreshFails(t *testing.T) {
140140
c.Assert(view, qt.Contains, "error: connection refused")
141141
}
142142

143+
func degradedList(captured time.Time, pid int) live.ConnectionList {
144+
list := live.NewConnectionList(captured, []live.Connection{{PID: pid, Instance: "primary"}}, live.SortByTransactionStart)
145+
list.Instances = []live.InstanceMeta{
146+
{ID: "primary", Role: "primary", Error: "remote service unavailable"},
147+
{ID: "replica-1", Role: "replica"},
148+
}
149+
return list
150+
}
151+
152+
func TestModelHoldsLastGoodListOnPersistentPartial(t *testing.T) {
153+
c := qt.New(t)
154+
model := NewModel(context.Background(), &clientStub{}, time.Second, 0)
155+
good := live.NewConnectionList(time.Now(), []live.Connection{{PID: 10, Instance: "primary"}}, live.SortByTransactionStart)
156+
157+
updated, _ := model.Update(listMsg{list: good})
158+
// A degraded refresh (client retries already exhausted) must not replace the
159+
// good frame or show the unreachable banner — it holds and goes stale.
160+
updated, _ = updated.(Model).Update(listMsg{list: degradedList(time.Now(), 20)})
161+
got := updated.(Model)
162+
163+
// Held the last good frame (PID 10); the degraded frame (PID 20) was not
164+
// swapped in. Assert on the held data, not the rendered string — the header
165+
// renders a wall-clock timestamp that can incidentally contain a PID.
166+
held := got.currentList().Connections
167+
c.Assert(len(held), qt.Equals, 1)
168+
c.Assert(held[0].PID, qt.Equals, 10)
169+
c.Assert(got.View(), qt.Not(qt.Contains), "unreachable")
170+
c.Assert(got.consecutiveErrors, qt.Equals, 1)
171+
}
172+
173+
func TestModelShowsPartialOnFirstLoadWithNoPriorFrame(t *testing.T) {
174+
c := qt.New(t)
175+
model := NewModel(context.Background(), &clientStub{}, time.Second, 0)
176+
177+
// No good frame to hold yet, so the first result is shown even if degraded.
178+
updated, _ := model.Update(listMsg{list: degradedList(time.Now(), 30)})
179+
got := updated.(Model)
180+
181+
// No prior frame to hold, so the degraded result is shown (banner and all).
182+
c.Assert(got.currentList().Connections[0].PID, qt.Equals, 30)
183+
c.Assert(got.View(), qt.Contains, "unreachable")
184+
}
185+
143186
func TestModelShowsInitialListErrorBeforeAnySuccessfulList(t *testing.T) {
144187
c := qt.New(t)
145188
model := NewModel(context.Background(), &clientStub{}, time.Second, 0)

0 commit comments

Comments
 (0)