@@ -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+
529667func 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+
558726func TestClient_ListHonorsRetryAfterHeader (t * testing.T ) {
559727 var calls atomic.Int32
560728 srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
0 commit comments