-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(core): retry streaming transport errors wrapped by ModelHttpException without status code #3058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
521109d
9d6b8b5
826495a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -103,7 +103,11 @@ private static boolean isRetryableError(Throwable error) { | |
| return hte.isRetryable(); | ||
| } | ||
|
|
||
| if (error instanceof ModelHttpException mhe) { | ||
| // Only treat a ModelHttpException as an HTTP response error when a status code is | ||
| // present. Implementations without a status code (e.g. OpenAIException wrapping a | ||
| // streaming transport failure) must fall through to the transport/IO and cause-chain | ||
| // checks below instead of being classified as a permanent client error (issue #3057). | ||
| if (error instanceof ModelHttpException mhe && mhe.getStatusCode() != null) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Please avoid retrying after a streaming response has already emitted any chunks. With this change, a null-status ModelHttpException can fall through to a retryable transport/IO cause regardless of stream progress. ModelUtils applies retryWhen to the entire response Flux, so if the first attempt emits partial text or tool-call deltas and then the connection resets, resubscription reissues the request and downstream observes the first partial output followed by the second response, which can duplicate text or tool calls. AgentScope Python and the OpenAI Python SDK limit automatic retries to establishing the request/stream rather than consuming an already-started stream. Could we guard retries so they are allowed only before the first ChatResponse is emitted, and add tests covering both a reset before the first chunk (retries) and a reset after one chunk (does not retry)?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the careful review — you're right, resubscribing after partial output would duplicate already-delivered chunks downstream. I've pushed a guard in Added the two tests you asked for in
Commit history was rewritten to keep the two changes as separate commits. |
||
| return mhe.isRetryableHttpStatus(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,10 @@ | |
| */ | ||
| package io.agentscope.core.model; | ||
|
|
||
| import io.agentscope.core.message.ContentBlock; | ||
| import java.time.Duration; | ||
| import java.util.List; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import java.util.function.Predicate; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
@@ -110,25 +113,88 @@ public static Flux<ChatResponse> applyTimeoutAndRetry( | |
| if (retryOn == null) { | ||
| retryOn = error -> true; // retry all errors by default | ||
| } | ||
| // Lambda captures below require effectively final variables | ||
| final int retryCount = maxAttempts - 1; | ||
| final Duration effectiveInitialBackoff = initialBackoff; | ||
| final Duration effectiveMaxBackoff = maxBackoff; | ||
| final Predicate<Throwable> effectiveRetryOn = retryOn; | ||
|
|
||
| Retry retrySpec = | ||
| Retry.backoff(maxAttempts - 1, initialBackoff) | ||
| .maxBackoff(maxBackoff) | ||
| .jitter(0.5) | ||
| .filter(retryOn) | ||
| .doBeforeRetry( | ||
| signal -> | ||
| LOG.warn( | ||
| "Retrying model request (attempt {}/{}) due" | ||
| + " to: {}", | ||
| signal.totalRetriesInARow() + 1, | ||
| maxAttempts - 1, | ||
| signal.failure().getMessage(), | ||
| signal.failure())); | ||
|
|
||
| responseFlux = responseFlux.retryWhen(retrySpec); | ||
| // Retrying is only safe before user-visible content is emitted: resubscribing | ||
| // after partial output would reissue the request and downstream would observe | ||
| // the first partial response followed by the retried one, duplicating content. | ||
| // The visibility flag is created inside defer so every subscription (model | ||
| // call) gets a fresh flag, while it persists across retry attempts of the same | ||
| // call. Role-only or usage-only chunks carry no content blocks and do not | ||
| // disable retries — nothing user-visible has been delivered yet. | ||
| final Flux<ChatResponse> source = responseFlux; | ||
| responseFlux = | ||
| Flux.defer( | ||
| () -> { | ||
| AtomicBoolean emittedVisibleContent = new AtomicBoolean(false); | ||
| return source.doOnNext( | ||
| response -> { | ||
| if (hasVisibleContent(response)) { | ||
| emittedVisibleContent.set(true); | ||
| } | ||
| }) | ||
| .retryWhen( | ||
| Retry.backoff( | ||
| retryCount, | ||
| effectiveInitialBackoff) | ||
| .maxBackoff(effectiveMaxBackoff) | ||
| .jitter(0.5) | ||
| .filter( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The emission guard is the right call — retrying after partial output would duplicate content. But
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — role-only/usage-only chunks do map to
|
||
| error -> { | ||
| if (emittedVisibleContent | ||
| .get()) { | ||
| if (effectiveRetryOn | ||
| .test(error)) { | ||
| LOG.warn( | ||
| "Skipping" | ||
| + " retry" | ||
| + " of retryable" | ||
| + " error" | ||
| + " because" | ||
| + " visible" | ||
| + " content" | ||
| + " was already" | ||
| + " emitted" | ||
| + " (retrying" | ||
| + " would" | ||
| + " duplicate" | ||
| + " it):" | ||
| + " model={}," | ||
| + " error={}", | ||
| modelName, | ||
| error | ||
| .getMessage()); | ||
| } | ||
| return false; | ||
| } | ||
| return effectiveRetryOn | ||
| .test(error); | ||
| }) | ||
| .doBeforeRetry( | ||
| signal -> | ||
| LOG.warn( | ||
| "Retrying model" | ||
| + " request" | ||
| + " (attempt" | ||
| + " {}/{})" | ||
| + " due to:" | ||
| + " {}", | ||
| signal | ||
| .totalRetriesInARow() | ||
| + 1, | ||
| retryCount, | ||
| signal.failure() | ||
| .getMessage(), | ||
| signal | ||
| .failure()))); | ||
| }); | ||
| LOG.debug( | ||
| "Applied retry config: maxAttempts={}, initialBackoff={} for model: {}", | ||
| "Applied retry config: maxAttempts={}, initialBackoff={}," | ||
| + " retryBeforeFirstResponse=true for model: {}", | ||
| maxAttempts, | ||
| initialBackoff, | ||
| modelName); | ||
|
|
@@ -138,6 +204,20 @@ public static Flux<ChatResponse> applyTimeoutAndRetry( | |
| return responseFlux; | ||
| } | ||
|
|
||
| /** | ||
| * Whether the response carries user-visible content (text, thinking or tool-call deltas). | ||
| * | ||
| * <p>Role-only or usage-only chunks carry no content blocks — nothing user-visible has | ||
| * been delivered, so retrying past them cannot duplicate content.</p> | ||
| * | ||
| * @param response the response chunk to inspect | ||
| * @return true if the chunk contains at least one content block | ||
| */ | ||
| private static boolean hasVisibleContent(ChatResponse response) { | ||
| List<ContentBlock> content = response.getContent(); | ||
| return content != null && !content.isEmpty(); | ||
| } | ||
|
|
||
| /** | ||
| * Ensures GenerateOptions has MODEL_DEFAULTS for executionConfig applied. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good fix — the status-code branch no longer short-circuits the cause-chain checks when
getStatusCode()isnull, andshouldNotRetryModelHttpExceptionWithoutStatusCodekeeps the old contract. Since this changes retry behaviour for every provider implementingModelHttpException, please confirm the non-streaming 4xx paths still land inisRetryableHttpStatus()in the extension-module tests (openai/anthropic/dashscope), not just the openai one.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked the other model extensions: only the openai module has a
ModelHttpExceptionimplementation (OpenAIException). The anthropic and dashscope models surface transport failures as plainHttpTransportException(no status code for connection errors, 4xx/5xx with status codes), so their classification goes through theHttpTransportExceptionbranch ofisRetryableErrorand is unchanged by this fix. To pin that down, 826495a addsAnthropicRetryClassificationTestandDashScopeRetryClassificationTestasserting: connection error without status → retryable, 429/5xx → retryable, 400/401 → not retryable.