Skip to content

Commit 6bf3387

Browse files
feat: add retryable exception
1 parent a21fc50 commit 6bf3387

File tree

4 files changed

+92
-3
lines changed

4 files changed

+92
-3
lines changed

README.md

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

230230
- [`OrbIoException`](orb-kotlin-core/src/main/kotlin/com/withorb/api/errors/OrbIoException.kt): I/O networking errors.
231231

232+
- [`OrbRetryableException`](orb-kotlin-core/src/main/kotlin/com/withorb/api/errors/OrbRetryableException.kt): Generic error indicating a failure that could be retried by the client.
233+
232234
- [`OrbInvalidDataException`](orb-kotlin-core/src/main/kotlin/com/withorb/api/errors/OrbInvalidDataException.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.
233235

234236
- [`OrbException`](orb-kotlin-core/src/main/kotlin/com/withorb/api/errors/OrbException.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.

orb-kotlin-core/src/main/kotlin/com/withorb/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.withorb.api.core.http
33
import com.withorb.api.core.RequestOptions
44
import com.withorb.api.core.checkRequired
55
import com.withorb.api.errors.OrbIoException
6+
import com.withorb.api.errors.OrbRetryableException
67
import java.io.IOException
78
import java.time.Clock
89
import java.time.Duration
@@ -159,9 +160,10 @@ private constructor(
159160
}
160161

161162
private fun shouldRetry(throwable: Throwable): Boolean =
162-
// Only retry IOException and OrbIoException, other exceptions are not intended to be
163-
// retried.
164-
throwable is IOException || throwable is OrbIoException
163+
// Only retry known retryable exceptions, other exceptions are not intended to be retried.
164+
throwable is IOException ||
165+
throwable is OrbIoException ||
166+
throwable is OrbRetryableException
165167

166168
private fun getRetryBackoffDuration(retries: Int, response: HttpResponse?): Duration {
167169
// About the Retry-After header:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.withorb.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 OrbRetryableException constructor(message: String? = null, cause: Throwable? = null) :
13+
OrbException(message, cause)

orb-kotlin-core/src/test/kotlin/com/withorb/api/core/http/RetryingHttpClientTest.kt

Lines changed: 72 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.withorb.api.client.okhttp.OkHttpClient
88
import com.withorb.api.core.RequestOptions
9+
import com.withorb.api.errors.OrbRetryableException
910
import java.io.InputStream
1011
import java.time.Duration
1112
import kotlinx.coroutines.runBlocking
@@ -250,6 +251,77 @@ internal class RetryingHttpClientTest {
250251
assertNoResponseLeaks()
251252
}
252253

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

0 commit comments

Comments
 (0)