11import { describe , it , expect , afterEach } from 'bun:test' ;
2- import { requestJson } from '../../src/client/http' ;
2+ import { requestJson , retryDelayMs } from '../../src/client/http' ;
33import { CLI_VERSION } from '../../src/version' ;
44import { createMockServer , jsonResponse , type MockServer } from '../helpers/mock-server' ;
55import type { Config } from '../../src/config/schema' ;
@@ -23,9 +23,11 @@ function makeConfig(baseUrl: string): Config {
2323
2424describe ( 'HTTP client' , ( ) => {
2525 let server : MockServer ;
26+ const originalFetch = globalThis . fetch ;
2627
2728 afterEach ( ( ) => {
2829 server ?. close ( ) ;
30+ globalThis . fetch = originalFetch ;
2931 } ) ;
3032
3133 it ( 'makes authenticated GET request' , async ( ) => {
@@ -93,4 +95,183 @@ describe('HTTP client', () => {
9395 requestJson ( config , { url : `${ server . url } /v1/test` } ) ,
9496 ) . rejects . toThrow ( 'Rate limit' ) ;
9597 } ) ;
98+
99+ it . each ( [ 408 , 429 , 500 , 502 , 503 , 504 ] ) ( 'retries HTTP %d and then succeeds' , async ( status ) => {
100+ let attempts = 0 ;
101+ server = createMockServer ( {
102+ routes : {
103+ '/v1/test' : ( ) => {
104+ attempts ++ ;
105+ if ( attempts < 3 ) {
106+ return new Response ( 'transient' , {
107+ status,
108+ headers : { 'Retry-After' : '0' } ,
109+ } ) ;
110+ }
111+ return jsonResponse ( { result : 'ok' } ) ;
112+ } ,
113+ } ,
114+ } ) ;
115+
116+ const result = await requestJson < { result : string } > ( makeConfig ( server . url ) , {
117+ url : `${ server . url } /v1/test` ,
118+ } ) ;
119+
120+ expect ( result . result ) . toBe ( 'ok' ) ;
121+ expect ( attempts ) . toBe ( 3 ) ;
122+ } ) ;
123+
124+ it ( 'stops after the bounded number of attempts' , async ( ) => {
125+ let attempts = 0 ;
126+ server = createMockServer ( {
127+ routes : {
128+ '/v1/test' : ( ) => {
129+ attempts ++ ;
130+ return new Response ( 'unavailable' , {
131+ status : 503 ,
132+ headers : { 'Retry-After' : '0' } ,
133+ } ) ;
134+ } ,
135+ } ,
136+ } ) ;
137+
138+ await expect (
139+ requestJson ( makeConfig ( server . url ) , { url : `${ server . url } /v1/test` } ) ,
140+ ) . rejects . toThrow ( 'HTTP 503' ) ;
141+ expect ( attempts ) . toBe ( 3 ) ;
142+ } ) ;
143+
144+ it . each ( [ 400 , 401 , 403 , 404 , 409 , 422 ] ) ( 'does not retry permanent HTTP %d errors' , async ( status ) => {
145+ let attempts = 0 ;
146+ server = createMockServer ( {
147+ routes : {
148+ '/v1/test' : ( ) => {
149+ attempts ++ ;
150+ return jsonResponse ( { error : { message : 'permanent' } } , status ) ;
151+ } ,
152+ } ,
153+ } ) ;
154+
155+ await expect (
156+ requestJson ( makeConfig ( server . url ) , { url : `${ server . url } /v1/test` } ) ,
157+ ) . rejects . toBeInstanceOf ( Error ) ;
158+ expect ( attempts ) . toBe ( 1 ) ;
159+ } ) ;
160+
161+ it ( 'retries transient network failures' , async ( ) => {
162+ let attempts = 0 ;
163+ globalThis . fetch = Object . assign (
164+ async ( ...args : Parameters < typeof fetch > ) => {
165+ attempts ++ ;
166+ if ( attempts === 1 ) throw new TypeError ( 'connection reset' ) ;
167+ return originalFetch ( ...args ) ;
168+ } ,
169+ { preconnect : originalFetch . preconnect } ,
170+ ) ;
171+ server = createMockServer ( {
172+ routes : { '/v1/test' : ( ) => jsonResponse ( { result : 'ok' } ) } ,
173+ } ) ;
174+
175+ const result = await requestJson < { result : string } > ( makeConfig ( server . url ) , {
176+ url : `${ server . url } /v1/test` ,
177+ } ) ;
178+
179+ expect ( result . result ) . toBe ( 'ok' ) ;
180+ expect ( attempts ) . toBe ( 2 ) ;
181+ } ) ;
182+
183+ it ( 'retries per-attempt timeout failures' , async ( ) => {
184+ let attempts = 0 ;
185+ globalThis . fetch = Object . assign (
186+ async ( ...args : Parameters < typeof fetch > ) => {
187+ attempts ++ ;
188+ if ( attempts === 1 ) throw new DOMException ( 'timed out' , 'TimeoutError' ) ;
189+ return originalFetch ( ...args ) ;
190+ } ,
191+ { preconnect : originalFetch . preconnect } ,
192+ ) ;
193+ server = createMockServer ( {
194+ routes : { '/v1/test' : ( ) => jsonResponse ( { result : 'ok' } ) } ,
195+ } ) ;
196+
197+ const result = await requestJson < { result : string } > ( makeConfig ( server . url ) , {
198+ url : `${ server . url } /v1/test` ,
199+ } ) ;
200+
201+ expect ( result . result ) . toBe ( 'ok' ) ;
202+ expect ( attempts ) . toBe ( 2 ) ;
203+ } ) ;
204+
205+ it ( 'replays JSON request bodies on retry' , async ( ) => {
206+ const bodies : unknown [ ] = [ ] ;
207+ server = createMockServer ( {
208+ routes : {
209+ '/v1/test' : async ( req ) => {
210+ bodies . push ( await req . json ( ) ) ;
211+ if ( bodies . length === 1 ) {
212+ return new Response ( 'unavailable' , {
213+ status : 503 ,
214+ headers : { 'Retry-After' : '0' } ,
215+ } ) ;
216+ }
217+ return jsonResponse ( { result : 'ok' } ) ;
218+ } ,
219+ } ,
220+ } ) ;
221+
222+ await requestJson ( makeConfig ( server . url ) , {
223+ url : `${ server . url } /v1/test` ,
224+ method : 'POST' ,
225+ body : { hello : 'world' } ,
226+ } ) ;
227+
228+ expect ( bodies ) . toEqual ( [ { hello : 'world' } , { hello : 'world' } ] ) ;
229+ } ) ;
230+
231+ it ( 'replays FormData request bodies on retry' , async ( ) => {
232+ const values : string [ ] = [ ] ;
233+ server = createMockServer ( {
234+ routes : {
235+ '/v1/test' : async ( req ) => {
236+ const form = await req . formData ( ) ;
237+ values . push ( String ( form . get ( 'purpose' ) ) ) ;
238+ if ( values . length === 1 ) {
239+ return new Response ( 'unavailable' , {
240+ status : 503 ,
241+ headers : { 'Retry-After' : '0' } ,
242+ } ) ;
243+ }
244+ return jsonResponse ( { result : 'ok' } ) ;
245+ } ,
246+ } ,
247+ } ) ;
248+ const body = new FormData ( ) ;
249+ body . append ( 'purpose' , 'retrieval' ) ;
250+
251+ await requestJson ( makeConfig ( server . url ) , {
252+ url : `${ server . url } /v1/test` ,
253+ method : 'POST' ,
254+ body,
255+ } ) ;
256+
257+ expect ( values ) . toEqual ( [ 'retrieval' , 'retrieval' ] ) ;
258+ } ) ;
259+ } ) ;
260+
261+ describe ( 'retryDelayMs' , ( ) => {
262+ it ( 'uses deterministic exponential backoff with injected jitter' , ( ) => {
263+ expect ( retryDelayMs ( 1 , null , 0 , 1 ) ) . toBe ( 250 ) ;
264+ expect ( retryDelayMs ( 2 , null , 0 , 0.5 ) ) . toBe ( 250 ) ;
265+ } ) ;
266+
267+ it ( 'supports Retry-After seconds and HTTP dates' , ( ) => {
268+ const now = Date . parse ( '2026-08-05T00:00:00Z' ) ;
269+ expect ( retryDelayMs ( 1 , '2' , now , 0 ) ) . toBe ( 2_000 ) ;
270+ expect ( retryDelayMs ( 1 , 'Wed, 05 Aug 2026 00:00:03 GMT' , now , 0 ) ) . toBe ( 3_000 ) ;
271+ } ) ;
272+
273+ it ( 'caps Retry-After and falls back for invalid values' , ( ) => {
274+ expect ( retryDelayMs ( 1 , '120' , 0 , 0 ) ) . toBe ( 30_000 ) ;
275+ expect ( retryDelayMs ( 1 , 'invalid' , 0 , 1 ) ) . toBe ( 250 ) ;
276+ } ) ;
96277} ) ;
0 commit comments