Skip to content

Commit f679fac

Browse files
feat: add retryable exception
1 parent a1e74ac commit f679fac

4 files changed

Lines changed: 99 additions & 4 deletions

File tree

README.md

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

222222
- [`OnebusawaySdkIoException`](onebusaway-sdk-java-core/src/main/kotlin/org/onebusaway/errors/OnebusawaySdkIoException.kt): I/O networking errors.
223223

224+
- [`OnebusawaySdkRetryableException`](onebusaway-sdk-java-core/src/main/kotlin/org/onebusaway/errors/OnebusawaySdkRetryableException.kt): Generic error indicating a failure that could be retried by the client.
225+
224226
- [`OnebusawaySdkInvalidDataException`](onebusaway-sdk-java-core/src/main/kotlin/org/onebusaway/errors/OnebusawaySdkInvalidDataException.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.
225227

226228
- [`OnebusawaySdkException`](onebusaway-sdk-java-core/src/main/kotlin/org/onebusaway/errors/OnebusawaySdkException.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.

onebusaway-sdk-java-core/src/main/kotlin/org/onebusaway/core/http/RetryingHttpClient.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import kotlin.math.pow
1919
import org.onebusaway.core.RequestOptions
2020
import org.onebusaway.core.checkRequired
2121
import org.onebusaway.errors.OnebusawaySdkIoException
22+
import org.onebusaway.errors.OnebusawaySdkRetryableException
2223

2324
class RetryingHttpClient
2425
private constructor(
@@ -176,10 +177,10 @@ private constructor(
176177
}
177178

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

184185
private fun getRetryBackoffDuration(retries: Int, response: HttpResponse?): Duration {
185186
// About the Retry-After header:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.onebusaway.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 OnebusawaySdkRetryableException
13+
@JvmOverloads
14+
constructor(message: String? = null, cause: Throwable? = null) :
15+
OnebusawaySdkException(message, cause)

onebusaway-sdk-java-core/src/test/kotlin/org/onebusaway/core/http/RetryingHttpClientTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import org.junit.jupiter.params.ParameterizedTest
1414
import org.junit.jupiter.params.provider.ValueSource
1515
import org.onebusaway.client.okhttp.OkHttpClient
1616
import org.onebusaway.core.RequestOptions
17+
import org.onebusaway.errors.OnebusawaySdkRetryableException
1718

1819
@WireMockTest
1920
@ResourceLock("https://github.com/wiremock/wiremock/issues/169")
@@ -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 OnebusawaySdkRetryableException("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+
OnebusawaySdkRetryableException("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)