Skip to content

Commit 79bb987

Browse files
stainless-botstainless-app[bot]
authored andcommitted
chore(internal): speculative retry-after-ms support (#143)
1 parent 21a9b03 commit 79bb987

2 files changed

Lines changed: 62 additions & 23 deletions

File tree

lithic-java-core/src/main/kotlin/com/lithic/api/core/http/RetryingHttpClient.kt

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.lithic.api.core.RequestOptions
77
import com.lithic.api.errors.LithicIoException
88
import java.io.IOException
99
import java.time.Clock
10+
import java.time.Duration
1011
import java.time.OffsetDateTime
1112
import java.time.format.DateTimeFormatter
1213
import java.time.format.DateTimeParseException
@@ -59,7 +60,7 @@ private constructor(
5960
}
6061

6162
val backoffMillis = getRetryBackoffMillis(retries, response)
62-
Thread.sleep(backoffMillis)
63+
Thread.sleep(backoffMillis.toMillis())
6364
}
6465
}
6566

@@ -95,7 +96,7 @@ private constructor(
9596
}
9697

9798
val backoffMillis = getRetryBackoffMillis(retries, response)
98-
return sleepAsync(backoffMillis).thenCompose {
99+
return sleepAsync(backoffMillis.toMillis()).thenCompose {
99100
wrap(httpClient.executeAsync(request, requestOptions))
100101
}
101102
},
@@ -113,8 +114,7 @@ private constructor(
113114

114115
private fun isRetryable(request: HttpRequest): Boolean {
115116
// Some requests, such as when a request body is being streamed, cannot be retried because
116-
// the body data aren't
117-
// available on subsequent attempts.
117+
// the body data aren't available on subsequent attempts.
118118
return request.body?.repeatable() ?: true
119119
}
120120

@@ -151,39 +151,53 @@ private constructor(
151151

152152
private fun shouldRetry(throwable: Throwable): Boolean {
153153
// Only retry IOException and LithicIoException, other exceptions are not intended to be
154-
// retried
154+
// retried.
155155
return throwable is IOException || throwable is LithicIoException
156156
}
157157

158-
private fun getRetryBackoffMillis(retries: Int, response: HttpResponse?): Long {
158+
private fun getRetryBackoffMillis(retries: Int, response: HttpResponse?): Duration {
159159
// About the Retry-After header:
160160
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
161-
val retryAfter =
162-
response?.headers()?.get("Retry-After")?.getOrNull(0)?.let { retryAfter ->
163-
retryAfter.toLongOrNull()
164-
?: try {
165-
ChronoUnit.SECONDS.between(
166-
OffsetDateTime.now(clock),
167-
OffsetDateTime.parse(retryAfter, DateTimeFormatter.RFC_1123_DATE_TIME)
168-
)
169-
} catch (e: DateTimeParseException) {
170-
null
161+
response
162+
?.headers()
163+
?.let { headers ->
164+
headers
165+
.get("Retry-After-Ms")
166+
.getOrNull(0)
167+
?.toFloatOrNull()
168+
?.times(TimeUnit.MILLISECONDS.toNanos(1))
169+
?: headers.get("Retry-After").getOrNull(0)?.let { retryAfter ->
170+
retryAfter.toFloatOrNull()?.times(TimeUnit.SECONDS.toNanos(1))
171+
?: try {
172+
ChronoUnit.MILLIS.between(
173+
OffsetDateTime.now(clock),
174+
OffsetDateTime.parse(
175+
retryAfter,
176+
DateTimeFormatter.RFC_1123_DATE_TIME
177+
)
178+
)
179+
} catch (e: DateTimeParseException) {
180+
null
181+
}
171182
}
172183
}
173-
174-
// If the API asks us to wait a certain amount of time (and it's a reasonable amount), just
175-
// do what it says.
176-
if (retryAfter != null && retryAfter in 1..60) {
177-
return TimeUnit.SECONDS.toMillis(retryAfter)
178-
}
184+
?.let { retryAfterNanos ->
185+
// If the API asks us to wait a certain amount of time (and it's a reasonable
186+
// amount), just
187+
// do what it says.
188+
val retryAfter = Duration.ofNanos(retryAfterNanos.toLong())
189+
if (retryAfter in Duration.ofNanos(0)..Duration.ofMinutes(1)) {
190+
return retryAfter
191+
}
192+
}
179193

180194
// Apply exponential backoff, but not more than the max.
181195
val backoffSeconds = min(0.5 * 2.0.pow(retries - 1), 8.0)
182196

183197
// Apply some jitter
184198
val jitter = 1.0 - 0.25 * ThreadLocalRandom.current().nextDouble()
185199

186-
return (TimeUnit.SECONDS.toMillis(1) * backoffSeconds * jitter).toLong()
200+
return Duration.ofNanos((TimeUnit.SECONDS.toNanos(1) * backoffSeconds * jitter).toLong())
187201
}
188202

189203
private fun sleepAsync(millis: Long): CompletableFuture<Void> {

lithic-java-core/src/test/kotlin/com/lithic/api/core/http/RetryingHttpClientTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,29 @@ internal class RetryingHttpClientTest {
8383
assertThat(response.statusCode()).isEqualTo(200)
8484
verify(3, postRequestedFor(urlPathEqualTo("/something")))
8585
}
86+
87+
@Test
88+
fun retryAfterMsHeader() {
89+
val request =
90+
HttpRequest.builder().method(HttpMethod.POST).addPathSegment("something").build()
91+
stubFor(
92+
post(urlPathEqualTo("/something"))
93+
.inScenario("foo")
94+
.whenScenarioStateIs(Scenario.STARTED)
95+
.willReturn(serviceUnavailable().withHeader("Retry-After-Ms", "10"))
96+
.willSetStateTo("RETRY_AFTER_DELAY")
97+
)
98+
stubFor(
99+
post(urlPathEqualTo("/something"))
100+
.inScenario("foo") // then we return a success
101+
.whenScenarioStateIs("RETRY_AFTER_DELAY")
102+
.willReturn(ok())
103+
.willSetStateTo("COMPLETED")
104+
)
105+
val retryingClient =
106+
RetryingHttpClient.builder().httpClient(httpClient).maxRetries(1).build()
107+
val response = retryingClient.execute(request)
108+
assertThat(response.statusCode()).isEqualTo(200)
109+
verify(2, postRequestedFor(urlPathEqualTo("/something")))
110+
}
86111
}

0 commit comments

Comments
 (0)