@@ -29,7 +29,7 @@ import { receiveSimControls, startSimReceivers } from '@/lib/mothership/transpor
2929const mocks = {
3030 fetch : mothershipGoFetchMockFns . mockFetchGo ,
3131 execute,
32- sleep : utilsHelpersMockFns . mockSleep ,
32+ sleep : utilsHelpersMockFns . mockInterruptibleSleep ,
3333}
3434
3535function request ( ) {
@@ -232,3 +232,77 @@ describe('outbound receiver lifecycle', () => {
232232 }
233233 )
234234} )
235+
236+ describe ( 'outbound receiver backoff' , ( ) => {
237+ const logger = ( ) => getMockLogger ( 'MothershipTransportReceiver' )
238+
239+ beforeEach ( ( ) => {
240+ vi . resetAllMocks ( )
241+ mocks . sleep . mockResolvedValue ( undefined )
242+ } )
243+
244+ /** Runs the receiver over scripted poll outcomes and returns each wait it took. */
245+ async function delaysFor ( outcomes : ( ( ) => Response ) [ ] ) : Promise < number [ ] > {
246+ const controller = new AbortController ( )
247+ let polls = 0
248+ mocks . fetch . mockImplementation ( async ( ) => {
249+ const next = outcomes [ polls ++ ]
250+ if ( next ) return next ( )
251+ controller . abort ( )
252+ throw new Error ( 'shutdown' )
253+ } )
254+ await receiveSimControls ( 'https://worker.test' , 'a' . repeat ( 64 ) , controller . signal )
255+ for ( const [ , signal ] of mocks . sleep . mock . calls ) expect ( signal ) . toBe ( controller . signal )
256+ return mocks . sleep . mock . calls . map ( ( [ ms ] ) => ms )
257+ }
258+
259+ const refused = ( status : number ) => ( ) => new Response ( null , { status } )
260+ const lost = ( ) => {
261+ throw new Error ( 'connection lost' )
262+ }
263+ const empty = ( ) => Response . json ( { requests : [ ] } )
264+
265+ it . each ( [ 401 , 403 ] ) (
266+ 'waits minutes, not seconds, between polls the worker refuses (%i)' ,
267+ async ( status ) => {
268+ const delays = await delaysFor ( Array . from ( { length : 8 } , ( ) => refused ( status ) ) )
269+ expect ( delays ) . toHaveLength ( 8 )
270+ expect ( Math . min ( ...delays ) ) . toBeGreaterThanOrEqual ( 24_000 )
271+ expect ( delays [ 7 ] ) . toBeGreaterThanOrEqual ( 480_000 )
272+ expect ( Math . max ( ...delays ) ) . toBeLessThanOrEqual ( 720_000 )
273+ expect ( logger ( ) . warn ) . toHaveBeenCalledOnce ( )
274+ }
275+ )
276+
277+ it ( 'backs off transient failures to a ceiling under the 5 s run-control deadline, warning once' , async ( ) => {
278+ const delays = await delaysFor ( Array . from ( { length : 8 } , ( ) => lost ) )
279+ expect ( delays [ 0 ] ) . toBeLessThanOrEqual ( 1200 )
280+ expect ( delays [ 7 ] ) . toBeGreaterThanOrEqual ( 3200 )
281+ expect ( Math . max ( ...delays ) ) . toBeLessThan ( 5000 )
282+ expect ( logger ( ) . warn ) . toHaveBeenCalledOnce ( )
283+ } )
284+
285+ it ( 'starts over after a poll succeeds and says it reconnected' , async ( ) => {
286+ const delays = await delaysFor ( [ lost , lost , lost , empty , lost ] )
287+ expect ( delays ) . toHaveLength ( 4 )
288+ expect ( delays [ 2 ] ) . toBeGreaterThanOrEqual ( 3200 )
289+ expect ( delays [ 3 ] ) . toBeLessThanOrEqual ( 1200 )
290+ expect ( logger ( ) . warn ) . toHaveBeenCalledTimes ( 2 )
291+ expect ( logger ( ) . info ) . toHaveBeenCalledOnce ( )
292+ } )
293+
294+ it ( 'keeps backing off while polls succeed but their batches cannot be read' , async ( ) => {
295+ const malformed = ( ) => Response . json ( { requests : 'not a list' } )
296+ const delays = await delaysFor ( Array . from ( { length : 5 } , ( ) => malformed ) )
297+ expect ( delays [ 4 ] ) . toBeGreaterThanOrEqual ( 3200 )
298+ expect ( logger ( ) . warn ) . toHaveBeenCalledOnce ( )
299+ expect ( logger ( ) . info ) . not . toHaveBeenCalled ( )
300+ } )
301+
302+ it ( 'warns again, and backs off from the start, when an outage turns into a refused key' , async ( ) => {
303+ const delays = await delaysFor ( [ lost , lost , lost , refused ( 401 ) , refused ( 401 ) ] )
304+ expect ( delays [ 3 ] ) . toBeLessThanOrEqual ( 36_000 )
305+ expect ( delays [ 4 ] ) . toBeGreaterThanOrEqual ( 48_000 )
306+ expect ( logger ( ) . warn ) . toHaveBeenCalledTimes ( 2 )
307+ } )
308+ } )
0 commit comments