@@ -20,7 +20,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
2020import com.github.tomakehurst.wiremock.junit5.WireMockTest
2121import com.github.tomakehurst.wiremock.stubbing.Scenario
2222import java.io.InputStream
23+ import java.time.Clock
2324import java.time.Duration
25+ import java.time.OffsetDateTime
26+ import java.time.ZoneOffset
27+ import java.time.format.DateTimeFormatter
2428import java.util.concurrent.CompletableFuture
2529import org.assertj.core.api.Assertions.assertThat
2630import org.junit.jupiter.api.BeforeEach
@@ -36,6 +40,21 @@ internal class RetryingHttpClientTest {
3640 private lateinit var baseUrl: String
3741 private lateinit var httpClient: HttpClient
3842
43+ private class RecordingSleeper : Sleeper {
44+ val durations = mutableListOf<Duration >()
45+
46+ override fun sleep (duration : Duration ) {
47+ durations.add(duration)
48+ }
49+
50+ override fun sleepAsync (duration : Duration ): CompletableFuture <Void > {
51+ durations.add(duration)
52+ return CompletableFuture .completedFuture(null )
53+ }
54+
55+ override fun close () {}
56+ }
57+
3958 @BeforeEach
4059 fun beforeEach (wmRuntimeInfo : WireMockRuntimeInfo ) {
4160 baseUrl = wmRuntimeInfo.httpBaseUrl
@@ -86,7 +105,8 @@ internal class RetryingHttpClientTest {
86105 @ValueSource(booleans = [false , true ])
87106 fun execute (async : Boolean ) {
88107 stubFor(post(urlPathEqualTo(" /something" )).willReturn(ok()))
89- val retryingClient = retryingHttpClientBuilder().build()
108+ val sleeper = RecordingSleeper ()
109+ val retryingClient = retryingHttpClientBuilder(sleeper).build()
90110
91111 val response =
92112 retryingClient.execute(
@@ -100,6 +120,7 @@ internal class RetryingHttpClientTest {
100120
101121 assertThat(response.statusCode()).isEqualTo(200 )
102122 verify(1 , postRequestedFor(urlPathEqualTo(" /something" )))
123+ assertThat(sleeper.durations).isEmpty()
103124 assertNoResponseLeaks()
104125 }
105126
@@ -111,8 +132,12 @@ internal class RetryingHttpClientTest {
111132 .withHeader(" X-Some-Header" , matching(" stainless-java-retry-.+" ))
112133 .willReturn(ok())
113134 )
135+ val sleeper = RecordingSleeper ()
114136 val retryingClient =
115- retryingHttpClientBuilder().maxRetries(2 ).idempotencyHeader(" X-Some-Header" ).build()
137+ retryingHttpClientBuilder(sleeper)
138+ .maxRetries(2 )
139+ .idempotencyHeader(" X-Some-Header" )
140+ .build()
116141
117142 val response =
118143 retryingClient.execute(
@@ -126,20 +151,20 @@ internal class RetryingHttpClientTest {
126151
127152 assertThat(response.statusCode()).isEqualTo(200 )
128153 verify(1 , postRequestedFor(urlPathEqualTo(" /something" )))
154+ assertThat(sleeper.durations).isEmpty()
129155 assertNoResponseLeaks()
130156 }
131157
132158 @ParameterizedTest
133159 @ValueSource(booleans = [false , true ])
134160 fun execute_withRetryAfterHeader (async : Boolean ) {
161+ val retryAfterDate = " Wed, 21 Oct 2015 07:28:00 GMT"
135162 stubFor(
136163 post(urlPathEqualTo(" /something" ))
137164 // First we fail with a retry after header given as a date
138165 .inScenario(" foo" )
139166 .whenScenarioStateIs(Scenario .STARTED )
140- .willReturn(
141- serviceUnavailable().withHeader(" Retry-After" , " Wed, 21 Oct 2015 07:28:00 GMT" )
142- )
167+ .willReturn(serviceUnavailable().withHeader(" Retry-After" , retryAfterDate))
143168 .willSetStateTo(" RETRY_AFTER_DATE" )
144169 )
145170 stubFor(
@@ -158,7 +183,13 @@ internal class RetryingHttpClientTest {
158183 .willReturn(ok())
159184 .willSetStateTo(" COMPLETED" )
160185 )
161- val retryingClient = retryingHttpClientBuilder().maxRetries(2 ).build()
186+ // Fix the clock to 5 seconds before the Retry-After date so the date-based backoff is
187+ // deterministic.
188+ val retryAfterDateTime =
189+ OffsetDateTime .parse(retryAfterDate, DateTimeFormatter .RFC_1123_DATE_TIME )
190+ val clock = Clock .fixed(retryAfterDateTime.minusSeconds(5 ).toInstant(), ZoneOffset .UTC )
191+ val sleeper = RecordingSleeper ()
192+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2 ).build()
162193
163194 val response =
164195 retryingClient.execute(
@@ -186,19 +217,20 @@ internal class RetryingHttpClientTest {
186217 postRequestedFor(urlPathEqualTo(" /something" ))
187218 .withHeader(" x-stainless-retry-count" , equalTo(" 2" )),
188219 )
220+ assertThat(sleeper.durations)
221+ .containsExactly(Duration .ofSeconds(5 ), Duration .ofMillis(1234 ))
189222 assertNoResponseLeaks()
190223 }
191224
192225 @ParameterizedTest
193226 @ValueSource(booleans = [false , true ])
194227 fun execute_withOverwrittenRetryCountHeader (async : Boolean ) {
228+ val retryAfterDate = " Wed, 21 Oct 2015 07:28:00 GMT"
195229 stubFor(
196230 post(urlPathEqualTo(" /something" ))
197231 .inScenario(" foo" ) // first we fail with a retry after header given as a date
198232 .whenScenarioStateIs(Scenario .STARTED )
199- .willReturn(
200- serviceUnavailable().withHeader(" Retry-After" , " Wed, 21 Oct 2015 07:28:00 GMT" )
201- )
233+ .willReturn(serviceUnavailable().withHeader(" Retry-After" , retryAfterDate))
202234 .willSetStateTo(" RETRY_AFTER_DATE" )
203235 )
204236 stubFor(
@@ -208,7 +240,11 @@ internal class RetryingHttpClientTest {
208240 .willReturn(ok())
209241 .willSetStateTo(" COMPLETED" )
210242 )
211- val retryingClient = retryingHttpClientBuilder().maxRetries(2 ).build()
243+ val retryAfterDateTime =
244+ OffsetDateTime .parse(retryAfterDate, DateTimeFormatter .RFC_1123_DATE_TIME )
245+ val clock = Clock .fixed(retryAfterDateTime.minusSeconds(5 ).toInstant(), ZoneOffset .UTC )
246+ val sleeper = RecordingSleeper ()
247+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2 ).build()
212248
213249 val response =
214250 retryingClient.execute(
@@ -227,6 +263,7 @@ internal class RetryingHttpClientTest {
227263 postRequestedFor(urlPathEqualTo(" /something" ))
228264 .withHeader(" x-stainless-retry-count" , equalTo(" 42" )),
229265 )
266+ assertThat(sleeper.durations).containsExactly(Duration .ofSeconds(5 ))
230267 assertNoResponseLeaks()
231268 }
232269
@@ -247,7 +284,8 @@ internal class RetryingHttpClientTest {
247284 .willReturn(ok())
248285 .willSetStateTo(" COMPLETED" )
249286 )
250- val retryingClient = retryingHttpClientBuilder().maxRetries(1 ).build()
287+ val sleeper = RecordingSleeper ()
288+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1 ).build()
251289
252290 val response =
253291 retryingClient.execute(
@@ -261,6 +299,7 @@ internal class RetryingHttpClientTest {
261299
262300 assertThat(response.statusCode()).isEqualTo(200 )
263301 verify(2 , postRequestedFor(urlPathEqualTo(" /something" )))
302+ assertThat(sleeper.durations).containsExactly(Duration .ofMillis(10 ))
264303 assertNoResponseLeaks()
265304 }
266305
@@ -301,21 +340,12 @@ internal class RetryingHttpClientTest {
301340 override fun close () = httpClient.close()
302341 }
303342
343+ val sleeper = RecordingSleeper ()
304344 val retryingClient =
305345 RetryingHttpClient .builder()
306346 .httpClient(failingHttpClient)
307347 .maxRetries(2 )
308- .sleeper(
309- object : Sleeper {
310-
311- override fun sleep (duration : Duration ) {}
312-
313- override fun sleepAsync (duration : Duration ): CompletableFuture <Void > =
314- CompletableFuture .completedFuture(null )
315-
316- override fun close () {}
317- }
318- )
348+ .sleeper(sleeper)
319349 .build()
320350
321351 val response =
@@ -339,25 +369,153 @@ internal class RetryingHttpClientTest {
339369 postRequestedFor(urlPathEqualTo(" /something" ))
340370 .withHeader(" x-stainless-retry-count" , equalTo(" 0" )),
341371 )
372+ // Exponential backoff with jitter: 0.5s * jitter where jitter is in [0.75, 1.0].
373+ assertThat(sleeper.durations).hasSize(1 )
374+ assertThat(sleeper.durations[0 ]).isBetween(Duration .ofMillis(375 ), Duration .ofMillis(500 ))
342375 assertNoResponseLeaks()
343376 }
344377
345- private fun retryingHttpClientBuilder () =
346- RetryingHttpClient .builder()
347- .httpClient(httpClient)
348- // Use a no-op `Sleeper` to make the test fast.
349- .sleeper(
350- object : Sleeper {
378+ @ParameterizedTest
379+ @ValueSource(booleans = [false , true ])
380+ fun execute_withExponentialBackoff (async : Boolean ) {
381+ stubFor(post(urlPathEqualTo(" /something" )).willReturn(serviceUnavailable()))
382+ val sleeper = RecordingSleeper ()
383+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(3 ).build()
384+
385+ val response =
386+ retryingClient.execute(
387+ HttpRequest .builder()
388+ .method(HttpMethod .POST )
389+ .baseUrl(baseUrl)
390+ .addPathSegment(" something" )
391+ .build(),
392+ async,
393+ )
351394
352- override fun sleep (duration : Duration ) {}
395+ // All retries exhausted; the last 503 response is returned.
396+ assertThat(response.statusCode()).isEqualTo(503 )
397+ verify(4 , postRequestedFor(urlPathEqualTo(" /something" )))
398+ // Exponential backoff with jitter: backoff = min(0.5 * 2^(retries-1), 8) * jitter where
399+ // jitter is in [0.75, 1.0].
400+ assertThat(sleeper.durations).hasSize(3 )
401+ // retries=1: 0.5s * [0.75, 1.0]
402+ assertThat(sleeper.durations[0 ]).isBetween(Duration .ofMillis(375 ), Duration .ofMillis(500 ))
403+ // retries=2: 1.0s * [0.75, 1.0]
404+ assertThat(sleeper.durations[1 ]).isBetween(Duration .ofMillis(750 ), Duration .ofMillis(1000 ))
405+ // retries=3: 2.0s * [0.75, 1.0]
406+ assertThat(sleeper.durations[2 ]).isBetween(Duration .ofMillis(1500 ), Duration .ofMillis(2000 ))
407+ assertNoResponseLeaks()
408+ }
353409
354- override fun sleepAsync (duration : Duration ): CompletableFuture <Void > =
355- CompletableFuture .completedFuture(null )
410+ @ParameterizedTest
411+ @ValueSource(booleans = [false , true ])
412+ fun execute_withExponentialBackoffCap (async : Boolean ) {
413+ stubFor(post(urlPathEqualTo(" /something" )).willReturn(serviceUnavailable()))
414+ val sleeper = RecordingSleeper ()
415+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(6 ).build()
356416
357- override fun close () {}
358- }
417+ val response =
418+ retryingClient.execute(
419+ HttpRequest .builder()
420+ .method(HttpMethod .POST )
421+ .baseUrl(baseUrl)
422+ .addPathSegment(" something" )
423+ .build(),
424+ async,
359425 )
360426
427+ assertThat(response.statusCode()).isEqualTo(503 )
428+ verify(7 , postRequestedFor(urlPathEqualTo(" /something" )))
429+ assertThat(sleeper.durations).hasSize(6 )
430+ // retries=5: min(0.5 * 2^4, 8) = 8.0s * [0.75, 1.0]
431+ assertThat(sleeper.durations[4 ]).isBetween(Duration .ofMillis(6000 ), Duration .ofMillis(8000 ))
432+ // retries=6: min(0.5 * 2^5, 8) = min(16, 8) = 8.0s * [0.75, 1.0] (capped)
433+ assertThat(sleeper.durations[5 ]).isBetween(Duration .ofMillis(6000 ), Duration .ofMillis(8000 ))
434+ assertNoResponseLeaks()
435+ }
436+
437+ @ParameterizedTest
438+ @ValueSource(booleans = [false , true ])
439+ fun execute_withRetryAfterMsPriorityOverRetryAfter (async : Boolean ) {
440+ stubFor(
441+ post(urlPathEqualTo(" /something" ))
442+ .inScenario(" foo" )
443+ .whenScenarioStateIs(Scenario .STARTED )
444+ .willReturn(
445+ serviceUnavailable()
446+ .withHeader(" Retry-After-Ms" , " 50" )
447+ .withHeader(" Retry-After" , " 2" )
448+ )
449+ .willSetStateTo(" RETRY" )
450+ )
451+ stubFor(
452+ post(urlPathEqualTo(" /something" ))
453+ .inScenario(" foo" )
454+ .whenScenarioStateIs(" RETRY" )
455+ .willReturn(ok())
456+ .willSetStateTo(" COMPLETED" )
457+ )
458+ val sleeper = RecordingSleeper ()
459+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1 ).build()
460+
461+ val response =
462+ retryingClient.execute(
463+ HttpRequest .builder()
464+ .method(HttpMethod .POST )
465+ .baseUrl(baseUrl)
466+ .addPathSegment(" something" )
467+ .build(),
468+ async,
469+ )
470+
471+ assertThat(response.statusCode()).isEqualTo(200 )
472+ // Retry-After-Ms (50ms) takes priority over Retry-After (2s).
473+ assertThat(sleeper.durations).containsExactly(Duration .ofMillis(50 ))
474+ assertNoResponseLeaks()
475+ }
476+
477+ @ParameterizedTest
478+ @ValueSource(booleans = [false , true ])
479+ fun execute_withRetryAfterUnparseable (async : Boolean ) {
480+ stubFor(
481+ post(urlPathEqualTo(" /something" ))
482+ .inScenario(" foo" )
483+ .whenScenarioStateIs(Scenario .STARTED )
484+ .willReturn(serviceUnavailable().withHeader(" Retry-After" , " not-a-date-or-number" ))
485+ .willSetStateTo(" RETRY" )
486+ )
487+ stubFor(
488+ post(urlPathEqualTo(" /something" ))
489+ .inScenario(" foo" )
490+ .whenScenarioStateIs(" RETRY" )
491+ .willReturn(ok())
492+ .willSetStateTo(" COMPLETED" )
493+ )
494+ val sleeper = RecordingSleeper ()
495+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1 ).build()
496+
497+ val response =
498+ retryingClient.execute(
499+ HttpRequest .builder()
500+ .method(HttpMethod .POST )
501+ .baseUrl(baseUrl)
502+ .addPathSegment(" something" )
503+ .build(),
504+ async,
505+ )
506+
507+ assertThat(response.statusCode()).isEqualTo(200 )
508+ // Unparseable Retry-After falls through to exponential backoff.
509+ assertThat(sleeper.durations).hasSize(1 )
510+ assertThat(sleeper.durations[0 ]).isBetween(Duration .ofMillis(375 ), Duration .ofMillis(500 ))
511+ assertNoResponseLeaks()
512+ }
513+
514+ private fun retryingHttpClientBuilder (
515+ sleeper : RecordingSleeper ,
516+ clock : Clock = Clock .systemUTC(),
517+ ) = RetryingHttpClient .builder().httpClient(httpClient).sleeper(sleeper).clock(clock)
518+
361519 private fun HttpClient.execute (request : HttpRequest , async : Boolean ): HttpResponse =
362520 if (async) executeAsync(request).get() else execute(request)
363521
0 commit comments