Skip to content

Commit 9d0e333

Browse files
feat: add retryable exception
1 parent 0cf8803 commit 9d0e333

4 files changed

Lines changed: 98 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ The SDK throws custom unchecked exception types:
232232

233233
- [`LithicIoException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicIoException.kt): I/O networking errors.
234234

235+
- [`LithicRetryableException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicRetryableException.kt): Generic error indicating a failure that could be retried by the client.
236+
235237
- [`LithicInvalidDataException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicInvalidDataException.kt): Failure to interpret successfully parsed data. For example, when accessing a property that's supposed to be required, but the API unexpectedly omitted it from the response.
236238

237239
- [`LithicException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicException.kt): Base class for all exceptions. Most errors will result in one of the previously mentioned ones, but completely generic errors may be thrown using the base class.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.lithic.api.core.http
33
import com.lithic.api.core.RequestOptions
44
import com.lithic.api.core.checkRequired
55
import com.lithic.api.errors.LithicIoException
6+
import com.lithic.api.errors.LithicRetryableException
67
import java.io.IOException
78
import java.time.Clock
89
import java.time.Duration
@@ -176,9 +177,10 @@ private constructor(
176177
}
177178

178179
private fun shouldRetry(throwable: Throwable): Boolean =
179-
// Only retry IOException and LithicIoException, other exceptions are not intended to be
180-
// retried.
181-
throwable is IOException || throwable is LithicIoException
180+
// Only retry known retryable exceptions, other exceptions are not intended to be retried.
181+
throwable is IOException ||
182+
throwable is LithicIoException ||
183+
throwable is LithicRetryableException
182184

183185
private fun getRetryBackoffDuration(retries: Int, response: HttpResponse?): Duration {
184186
// About the Retry-After header:
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.lithic.api.errors
2+
3+
/**
4+
* Exception that indicates a transient error that can be retried.
5+
*
6+
* When this exception is thrown during an HTTP request, the SDK will automatically retry the
7+
* request up to the maximum number of retries.
8+
*
9+
* @param message A descriptive error message
10+
* @param cause The underlying cause of this exception, if any
11+
*/
12+
class LithicRetryableException
13+
@JvmOverloads
14+
constructor(message: String? = null, cause: Throwable? = null) : LithicException(message, cause)

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest
66
import com.github.tomakehurst.wiremock.stubbing.Scenario
77
import com.lithic.api.client.okhttp.OkHttpClient
88
import com.lithic.api.core.RequestOptions
9+
import com.lithic.api.errors.LithicRetryableException
910
import java.io.InputStream
1011
import java.time.Duration
1112
import java.util.concurrent.CompletableFuture
@@ -251,6 +252,82 @@ internal class RetryingHttpClientTest {
251252
assertNoResponseLeaks()
252253
}
253254

255+
@ParameterizedTest
256+
@ValueSource(booleans = [false, true])
257+
fun execute_withRetryableException(async: Boolean) {
258+
stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))
259+
260+
var callCount = 0
261+
val failingHttpClient =
262+
object : HttpClient {
263+
override fun execute(
264+
request: HttpRequest,
265+
requestOptions: RequestOptions,
266+
): HttpResponse {
267+
callCount++
268+
if (callCount == 1) {
269+
throw LithicRetryableException("Simulated retryable failure")
270+
}
271+
return httpClient.execute(request, requestOptions)
272+
}
273+
274+
override fun executeAsync(
275+
request: HttpRequest,
276+
requestOptions: RequestOptions,
277+
): CompletableFuture<HttpResponse> {
278+
callCount++
279+
if (callCount == 1) {
280+
val future = CompletableFuture<HttpResponse>()
281+
future.completeExceptionally(
282+
LithicRetryableException("Simulated retryable failure")
283+
)
284+
return future
285+
}
286+
return httpClient.executeAsync(request, requestOptions)
287+
}
288+
289+
override fun close() = httpClient.close()
290+
}
291+
292+
val retryingClient =
293+
RetryingHttpClient.builder()
294+
.httpClient(failingHttpClient)
295+
.maxRetries(2)
296+
.sleeper(
297+
object : RetryingHttpClient.Sleeper {
298+
299+
override fun sleep(duration: Duration) {}
300+
301+
override fun sleepAsync(duration: Duration): CompletableFuture<Void> =
302+
CompletableFuture.completedFuture(null)
303+
}
304+
)
305+
.build()
306+
307+
val response =
308+
retryingClient.execute(
309+
HttpRequest.builder()
310+
.method(HttpMethod.POST)
311+
.baseUrl(baseUrl)
312+
.addPathSegment("something")
313+
.build(),
314+
async,
315+
)
316+
317+
assertThat(response.statusCode()).isEqualTo(200)
318+
verify(
319+
1,
320+
postRequestedFor(urlPathEqualTo("/something"))
321+
.withHeader("x-stainless-retry-count", equalTo("1")),
322+
)
323+
verify(
324+
0,
325+
postRequestedFor(urlPathEqualTo("/something"))
326+
.withHeader("x-stainless-retry-count", equalTo("0")),
327+
)
328+
assertNoResponseLeaks()
329+
}
330+
254331
private fun retryingHttpClientBuilder() =
255332
RetryingHttpClient.builder()
256333
.httpClient(httpClient)

0 commit comments

Comments
 (0)