@@ -9,17 +9,20 @@ import (
99 "net/http"
1010 "net/http/httptest"
1111 "net/url"
12+ "sync"
1213 "testing"
1314 "time"
1415
1516 "github.com/rs/zerolog"
1617 "github.com/snyk/error-catalog-golang-public/snyk"
18+ "github.com/snyk/error-catalog-golang-public/snyk_errors"
1719 "github.com/stretchr/testify/assert"
1820 "github.com/stretchr/testify/require"
1921
2022 "github.com/cenkalti/backoff/v5"
2123
2224 "github.com/snyk/go-application-framework/pkg/configuration"
25+ "github.com/snyk/go-application-framework/pkg/ui/uitypes"
2326)
2427
2528// Helper to create a response
@@ -550,3 +553,184 @@ func Test_parseRetryDelay(t *testing.T) {
550553 })
551554 }
552555}
556+
557+ func Test_filterRetryError (t * testing.T ) {
558+ logger := zerolog .Nop ()
559+ rm := RetryMiddleware {logger : & logger }
560+
561+ t .Run ("retry exhausted returns nil" , func (t * testing.T ) {
562+ err := rm .filterRetryError (backoff .Permanent (errRetryNecessary ), 3 )
563+ assert .NoError (t , err )
564+ })
565+
566+ t .Run ("delay max exceeded returns nil" , func (t * testing.T ) {
567+ err := rm .filterRetryError (backoff .Permanent (errRetryDelayMaxExceeded ), 1 )
568+ assert .NoError (t , err )
569+ })
570+
571+ t .Run ("non-sentinel error passes through unchanged" , func (t * testing.T ) {
572+ originalErr := errors .New ("some other error" )
573+ err := rm .filterRetryError (originalErr , 1 )
574+ assert .Equal (t , originalErr , err )
575+ })
576+ }
577+
578+ // setupRetryMiddleware wires RetryMiddleware with a counting transport and the given config.
579+ func setupRetryMiddleware (
580+ t * testing.T ,
581+ logger * zerolog.Logger ,
582+ ui uitypes.UserInterface ,
583+ maxAttempts int ,
584+ roundTrip func (req * http.Request , attempt int ) (* http.Response , error ),
585+ ) (* RetryMiddleware , * int ) {
586+ t .Helper ()
587+ attemptCount := 0
588+ fn := func (req * http.Request ) (* http.Response , error ) {
589+ attemptCount ++
590+ return roundTrip (req , attemptCount )
591+ }
592+ rt := & failRoundtripper {t : t , roundTripFn : & fn }
593+ config := configuration .NewWithOpts ()
594+ config .Set (ConfigurationKeyRequestAttempts , maxAttempts )
595+ config .Set (configurationKeyRetryAfter , 1 )
596+ if ui != nil {
597+ return NewRetryMiddlewareWithUI (config , logger , rt , ui ), & attemptCount
598+ }
599+ return NewRetryMiddleware (config , logger , rt ), & attemptCount
600+ }
601+
602+ func Test_retryMiddleware_429_exhausted (t * testing.T ) {
603+ logger := zerolog .Nop ()
604+
605+ always429 := func (req * http.Request , _ int ) (* http.Response , error ) {
606+ return & http.Response {
607+ StatusCode : http .StatusTooManyRequests ,
608+ Header : http.Header {"Retry-After" : []string {"1" }},
609+ Request : req ,
610+ }, nil
611+ }
612+
613+ t .Run ("returns last 429 response and nil error" , func (t * testing.T ) {
614+ sut , attemptCount := setupRetryMiddleware (t , & logger , nil , 1 , always429 )
615+ resp , err := sut .RoundTrip (httptest .NewRequest (http .MethodGet , "/" , nil ))
616+
617+ assert .NoError (t , err )
618+ assert .NotNil (t , resp )
619+ assert .Equal (t , http .StatusTooManyRequests , resp .StatusCode )
620+ assert .Equal (t , 3 , * attemptCount , "429 override enforces at least 3 attempts even when maxAttempts=1" )
621+ })
622+
623+ t .Run ("shows rate-limit warnings during retries" , func (t * testing.T ) {
624+ trackingUI := & trackingUserInterface {}
625+ sut , attemptCount := setupRetryMiddleware (t , & logger , trackingUI , 1 , always429 )
626+ resp , err := sut .RoundTrip (httptest .NewRequest (http .MethodGet , "/" , nil ))
627+
628+ assert .NoError (t , err )
629+ assert .NotNil (t , resp )
630+ assert .Equal (t , http .StatusTooManyRequests , resp .StatusCode )
631+ assert .Equal (t , 3 , * attemptCount , "429 override enforces at least 3 attempts even when maxAttempts=1" )
632+
633+ trackingUI .mu .Lock ()
634+ defer trackingUI .mu .Unlock ()
635+ assert .NotEmpty (t , trackingUI .warnErrors , "Rate-limit warning should appear since 429 override causes retries" )
636+ })
637+ }
638+
639+ func Test_retryMiddleware_429_then_success (t * testing.T ) {
640+ logger := zerolog .Nop ()
641+
642+ once429ThenOK := func (req * http.Request , attempt int ) (* http.Response , error ) {
643+ if attempt < 2 {
644+ return & http.Response {
645+ StatusCode : http .StatusTooManyRequests ,
646+ Header : http.Header {},
647+ Request : req ,
648+ }, nil
649+ }
650+ return & http.Response {
651+ StatusCode : http .StatusOK ,
652+ Header : http.Header {},
653+ Request : req ,
654+ }, nil
655+ }
656+
657+ t .Run ("nil UI does not panic" , func (t * testing.T ) {
658+ sut , attemptCount := setupRetryMiddleware (t , & logger , nil , 3 , once429ThenOK )
659+ resp , err := sut .RoundTrip (httptest .NewRequest (http .MethodGet , "/" , nil ))
660+
661+ require .NoError (t , err )
662+ require .NotNil (t , resp )
663+ assert .Equal (t , http .StatusOK , resp .StatusCode )
664+ assert .Equal (t , 2 , * attemptCount )
665+ })
666+
667+ t .Run ("UI receives warn with correct payload" , func (t * testing.T ) {
668+ trackingUI := & trackingUserInterface {}
669+ sut , attemptCount := setupRetryMiddleware (t , & logger , trackingUI , 3 , once429ThenOK )
670+ resp , err := sut .RoundTrip (httptest .NewRequest (http .MethodGet , "/" , nil ))
671+
672+ require .NoError (t , err )
673+ require .NotNil (t , resp )
674+ assert .Equal (t , http .StatusOK , resp .StatusCode )
675+ assert .Equal (t , 2 , * attemptCount )
676+
677+ trackingUI .mu .Lock ()
678+ defer trackingUI .mu .Unlock ()
679+
680+ assert .NotEmpty (t , trackingUI .warnErrors , "Expected rate-limit wait warning via OutputError" )
681+ for _ , warnErr := range trackingUI .warnErrors {
682+ assert .Equal (t , "warn" , warnErr .Level )
683+ assert .Equal (t , "Rate limited" , warnErr .Title )
684+ assert .Contains (t , warnErr .Description , "Waiting" )
685+ assert .Contains (t , warnErr .Description , "before retry" )
686+ assert .Empty (t , warnErr .ErrorCode )
687+ assert .Zero (t , warnErr .StatusCode )
688+ }
689+ })
690+
691+ t .Run ("500 exhaustion does not show rate-limit warning" , func (t * testing.T ) {
692+ trackingUI := & trackingUserInterface {}
693+
694+ always500 := func (req * http.Request , _ int ) (* http.Response , error ) {
695+ return & http.Response {
696+ StatusCode : http .StatusInternalServerError ,
697+ Header : http.Header {},
698+ Request : req ,
699+ }, nil
700+ }
701+
702+ sut , _ := setupRetryMiddleware (t , & logger , trackingUI , 2 , always500 )
703+ resp , err := sut .RoundTrip (httptest .NewRequest (http .MethodGet , "/" , nil ))
704+
705+ assert .NoError (t , err )
706+ assert .NotNil (t , resp )
707+ assert .Equal (t , http .StatusInternalServerError , resp .StatusCode )
708+
709+ trackingUI .mu .Lock ()
710+ defer trackingUI .mu .Unlock ()
711+ assert .Empty (t , trackingUI .warnErrors , "500 retries should not produce rate-limit warnings" )
712+ })
713+ }
714+
715+ type trackingUserInterface struct {
716+ mu sync.Mutex
717+ warnErrors []snyk_errors.Error
718+ }
719+
720+ func (t * trackingUserInterface ) Output (string ) error { return nil }
721+ func (t * trackingUserInterface ) OutputError (err error , _ ... uitypes.Opts ) error {
722+ var catalogErr snyk_errors.Error
723+ if errors .As (err , & catalogErr ) {
724+ t .mu .Lock ()
725+ t .warnErrors = append (t .warnErrors , catalogErr )
726+ t .mu .Unlock ()
727+ }
728+ return nil
729+ }
730+ func (t * trackingUserInterface ) Input (string ) (string , error ) { return "" , nil }
731+ func (t * trackingUserInterface ) SelectOptions (string , []string ) (int , string , error ) {
732+ return 0 , "" , nil
733+ }
734+ func (t * trackingUserInterface ) NewProgressBar () uitypes.ProgressBar {
735+ return uitypes.EmptyProgressBar {}
736+ }
0 commit comments