Skip to content

Commit 1b28230

Browse files
feat(client): send retry count header (#314)
1 parent afcbc06 commit 1b28230

2 files changed

Lines changed: 92 additions & 8 deletions

File tree

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,16 @@ private constructor(
4040

4141
maybeAddIdempotencyHeader(request)
4242

43+
// Don't send the current retry count in the headers if the caller set their own value.
44+
val shouldSendRetryCount = !request.headers.containsKey("x-stainless-retry-count")
45+
4346
var retries = 0
4447

4548
while (true) {
49+
if (shouldSendRetryCount) {
50+
setRetryCountHeader(request, retries)
51+
}
52+
4653
val response =
4754
try {
4855
val response = httpClient.execute(request, requestOptions)
@@ -74,10 +81,21 @@ private constructor(
7481

7582
maybeAddIdempotencyHeader(request)
7683

84+
// Don't send the current retry count in the headers if the caller set their own value.
85+
val shouldSendRetryCount = !request.headers.containsKey("x-stainless-retry-count")
86+
7787
var retries = 0
7888

79-
fun wrap(future: CompletableFuture<HttpResponse>): CompletableFuture<HttpResponse> {
80-
return future
89+
fun executeWithRetries(
90+
request: HttpRequest,
91+
requestOptions: RequestOptions,
92+
): CompletableFuture<HttpResponse> {
93+
if (shouldSendRetryCount) {
94+
setRetryCountHeader(request, retries)
95+
}
96+
97+
return httpClient
98+
.executeAsync(request, requestOptions)
8199
.handleAsync(
82100
fun(
83101
response: HttpResponse?,
@@ -97,15 +115,15 @@ private constructor(
97115

98116
val backoffMillis = getRetryBackoffMillis(retries, response)
99117
return sleepAsync(backoffMillis.toMillis()).thenCompose {
100-
wrap(httpClient.executeAsync(request, requestOptions))
118+
executeWithRetries(request, requestOptions)
101119
}
102120
},
103121
MoreExecutors.directExecutor()
104122
)
105123
.thenCompose(Function.identity())
106124
}
107125

108-
return wrap(httpClient.executeAsync(request, requestOptions))
126+
return executeWithRetries(request, requestOptions)
109127
}
110128

111129
override fun close() {
@@ -118,6 +136,11 @@ private constructor(
118136
return request.body?.repeatable() ?: true
119137
}
120138

139+
private fun setRetryCountHeader(request: HttpRequest, retries: Int) {
140+
request.headers.removeAll("x-stainless-retry-count")
141+
request.headers.put("x-stainless-retry-count", retries.toString())
142+
}
143+
121144
private fun idempotencyKey(): String = "stainless-java-retry-${UUID.randomUUID()}"
122145

123146
private fun maybeAddIdempotencyHeader(request: HttpRequest) {

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

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.lithic.api.client.okhttp.OkHttpClient
88
import org.assertj.core.api.Assertions.assertThat
99
import org.junit.jupiter.api.BeforeEach
1010
import org.junit.jupiter.api.Test
11+
import org.junit.jupiter.params.ParameterizedTest
12+
import org.junit.jupiter.params.provider.ValueSource
1113

1214
@WireMockTest
1315
internal class RetryingHttpClientTest {
@@ -50,8 +52,9 @@ internal class RetryingHttpClientTest {
5052
verify(1, postRequestedFor(urlPathEqualTo("/something")))
5153
}
5254

53-
@Test
54-
fun retryAfterHeader() {
55+
@ParameterizedTest
56+
@ValueSource(booleans = [false, true])
57+
fun retryAfterHeader(async: Boolean) {
5558
val request =
5659
HttpRequest.builder().method(HttpMethod.POST).addPathSegment("something").build()
5760
stubFor(
@@ -79,9 +82,67 @@ internal class RetryingHttpClientTest {
7982
)
8083
val retryingClient =
8184
RetryingHttpClient.builder().httpClient(httpClient).maxRetries(2).build()
82-
val response = retryingClient.execute(request)
85+
86+
val response =
87+
if (async) retryingClient.executeAsync(request).get()
88+
else retryingClient.execute(request)
89+
8390
assertThat(response.statusCode()).isEqualTo(200)
84-
verify(3, postRequestedFor(urlPathEqualTo("/something")))
91+
verify(
92+
1,
93+
postRequestedFor(urlPathEqualTo("/something"))
94+
.withHeader("x-stainless-retry-count", equalTo("0"))
95+
)
96+
verify(
97+
1,
98+
postRequestedFor(urlPathEqualTo("/something"))
99+
.withHeader("x-stainless-retry-count", equalTo("1"))
100+
)
101+
verify(
102+
1,
103+
postRequestedFor(urlPathEqualTo("/something"))
104+
.withHeader("x-stainless-retry-count", equalTo("2"))
105+
)
106+
}
107+
108+
@ParameterizedTest
109+
@ValueSource(booleans = [false, true])
110+
fun overwriteRetryCountHeader(async: Boolean) {
111+
val request =
112+
HttpRequest.builder()
113+
.method(HttpMethod.POST)
114+
.addPathSegment("something")
115+
.putHeader("x-stainless-retry-count", "42")
116+
.build()
117+
stubFor(
118+
post(urlPathEqualTo("/something"))
119+
.inScenario("foo") // first we fail with a retry after header given as a date
120+
.whenScenarioStateIs(Scenario.STARTED)
121+
.willReturn(
122+
serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
123+
)
124+
.willSetStateTo("RETRY_AFTER_DATE")
125+
)
126+
stubFor(
127+
post(urlPathEqualTo("/something"))
128+
.inScenario("foo") // then we return a success
129+
.whenScenarioStateIs("RETRY_AFTER_DATE")
130+
.willReturn(ok())
131+
.willSetStateTo("COMPLETED")
132+
)
133+
val retryingClient =
134+
RetryingHttpClient.builder().httpClient(httpClient).maxRetries(2).build()
135+
136+
val response =
137+
if (async) retryingClient.executeAsync(request).get()
138+
else retryingClient.execute(request)
139+
140+
assertThat(response.statusCode()).isEqualTo(200)
141+
verify(
142+
2,
143+
postRequestedFor(urlPathEqualTo("/something"))
144+
.withHeader("x-stainless-retry-count", equalTo("42"))
145+
)
85146
}
86147

87148
@Test

0 commit comments

Comments
 (0)