Skip to content

Commit 41267b1

Browse files
feat: add retryable exception
1 parent 9082055 commit 41267b1

File tree

4 files changed

+98
-3
lines changed

4 files changed

+98
-3
lines changed

README.md

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

11851185
- [`OpenAIIoException`](openai-java-core/src/main/kotlin/com/openai/errors/OpenAIIoException.kt): I/O networking errors.
11861186

1187+
- [`OpenAIRetryableException`](openai-java-core/src/main/kotlin/com/openai/errors/OpenAIRetryableException.kt): Generic error indicating a failure that could be retried by the client.
1188+
11871189
- [`OpenAIInvalidDataException`](openai-java-core/src/main/kotlin/com/openai/errors/OpenAIInvalidDataException.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.
11881190

11891191
- [`OpenAIException`](openai-java-core/src/main/kotlin/com/openai/errors/OpenAIException.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.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.openai.core.http
33
import com.openai.core.RequestOptions
44
import com.openai.core.checkRequired
55
import com.openai.errors.OpenAIIoException
6+
import com.openai.errors.OpenAIRetryableException
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 OpenAIIoException, other exceptions are not intended to be
180-
// retried.
181-
throwable is IOException || throwable is OpenAIIoException
180+
// Only retry known retryable exceptions, other exceptions are not intended to be retried.
181+
throwable is IOException ||
182+
throwable is OpenAIIoException ||
183+
throwable is OpenAIRetryableException
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.openai.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 OpenAIRetryableException
13+
@JvmOverloads
14+
constructor(message: String? = null, cause: Throwable? = null) : OpenAIException(message, cause)

openai-java-core/src/test/kotlin/com/openai/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.openai.client.okhttp.OkHttpClient
88
import com.openai.core.RequestOptions
9+
import com.openai.errors.OpenAIRetryableException
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 OpenAIRetryableException("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+
OpenAIRetryableException("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)