This is an automated email from the ASF dual-hosted git repository.

hengyuss pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 17e68d7f4a feat(ai-proxy): harden configurable retry and fallback 
policy (#7668)
17e68d7f4a is described below

commit 17e68d7f4a183037b193fd0b4ca24645f5dac15a
Author: hengyuss <[email protected]>
AuthorDate: Tue Oct 20 14:08:03 2026 +0800

    feat(ai-proxy): harden configurable retry and fallback policy (#7668)
    
    * refactor(ai-proxy): classify retry and fallback failures
    
    * refactor(ai-proxy): centralize retry and fallback decisions
    
    * refactor(ai-proxy): add tests
    
    * feat(ai-proxy): configure retry policy
---
 .../src/main/resources/application.yml             |   7 +
 .../ai/proxy/enhanced/service/AiProxyEngine.java   | 107 +++++++---
 .../proxy/enhanced/service/AiProxyRetryPolicy.java |  97 +++++++++
 .../enhanced/service/AiProxyRetryProperties.java   | 134 ++++++++++++
 .../ai/proxy/enhanced/AiProxyPluginTest.java       |  10 +-
 .../service/AiProxyEngineOrchestrationTest.java    | 224 ++++++++++++++++++++-
 .../proxy/enhanced/service/AiProxyEngineTest.java  |   3 +-
 .../enhanced/service/AiProxyRetryPolicyTest.java   | 107 ++++++++++
 .../service/AiProxyRetryPropertiesTest.java        |  89 ++++++++
 .../ai/proxy/AiProxyPluginConfiguration.java       |  19 +-
 .../ai/proxy/AiProxyPluginConfigurationTest.java   |  40 ++++
 11 files changed, 787 insertions(+), 50 deletions(-)

diff --git a/shenyu-bootstrap/src/main/resources/application.yml 
b/shenyu-bootstrap/src/main/resources/application.yml
index 0bb10820dd..bd6896e6cc 100644
--- a/shenyu-bootstrap/src/main/resources/application.yml
+++ b/shenyu-bootstrap/src/main/resources/application.yml
@@ -330,6 +330,13 @@ shenyu:
 #  plugins:
 #    ai:
 #      proxy:
+#        retry:
+#          # Maximum retries after the initial non-streaming request. Default: 
3.
+#          max-retries: 3
+#          # Maximum retries before a streaming response emits its first 
chunk. Default: 1.
+#          streaming-max-retries: 1
+#          # Initial backoff for non-streaming retries. Subsequent delays 
double. Default: 1s.
+#          initial-backoff: 1s
 #        transport:
 #          # Maximum time allowed to establish a connection to the AI upstream.
 #          # Default: 30s.
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
index e146623e06..088fb9156e 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
@@ -50,24 +50,38 @@ public final class AiProxyEngine {
 
     private static final ObjectMapper OBJECT_MAPPER = new 
ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
 
+    private static final AiProxyRetryPolicy RETRY_POLICY = new 
AiProxyRetryPolicy();
+
     private final AiProxyProtocolFactory aiProxyProtocolFactory;
 
     private final AiProxyProviderFactory aiProxyProviderFactory;
 
     private final AiProxyTransport aiProxyTransport;
 
-    private final Duration retryBackoff;
-
-    public AiProxyEngine(final AiProxyProtocolFactory protocols, final 
AiProxyProviderFactory providers, final AiProxyTransport transport) {
-        this(protocols, providers, transport, Duration.ofSeconds(1));
+    private final AiProxyRetryProperties retryProperties;
+
+    public AiProxyEngine(
+            final AiProxyProtocolFactory protocols,
+            final AiProxyProviderFactory providers,
+            final AiProxyTransport transport) {
+        this(
+                protocols,
+                providers,
+                transport,
+                new AiProxyRetryProperties());
     }
 
     public AiProxyEngine(final AiProxyProtocolFactory protocols, final 
AiProxyProviderFactory providers,
-                        final AiProxyTransport transport, final Duration 
retryBackoff) {
+                         final AiProxyTransport transport, final 
AiProxyRetryProperties retryProperties) {
         this.aiProxyProtocolFactory = protocols;
         this.aiProxyProviderFactory = providers;
         this.aiProxyTransport = transport;
-        this.retryBackoff = retryBackoff;
+        this.retryProperties = Objects.requireNonNull(retryProperties, 
"retryProperties must not be null");
+    }
+
+    public AiProxyEngine(final AiProxyProtocolFactory protocols, final 
AiProxyProviderFactory providers,
+                        final AiProxyTransport transport, final Duration 
retryBackoff) {
+        this(protocols, providers, transport, new AiProxyRetryProperties(3, 1, 
retryBackoff));
     }
 
     /**
@@ -92,7 +106,8 @@ public final class AiProxyEngine {
                 copy.setProtocol(primary.protocol().name());
                 return copy;
             });
-            return attempt(requestBody, primary, fallback, primary.stream() ? 
1 : 3, responseConsumer, new AtomicBoolean());
+            final int maxRetries = 
retryProperties.maxRetries(primary.stream());
+            return attempt(requestBody, primary, fallback, maxRetries, 
maxRetries, responseConsumer, new AtomicBoolean());
         });
     }
 
@@ -108,48 +123,78 @@ public final class AiProxyEngine {
     }
 
     private Mono<Void> attempt(final String requestBody, final 
PreparedExchange prepared,
-                               final Optional<AiCommonConfig> fallback, final 
int retries,
+                               final Optional<AiCommonConfig> fallback, final 
int maxRetries, final int retriesRemaining,
                                final Function<AiUpstreamResponse, Mono<Void>> 
consumer, final AtomicBoolean emitted) {
         return Mono.defer(() -> aiProxyTransport.execute(prepared.request()))
                 .flatMap(response -> {
                     int status = response.getStatusCode();
-                    if (status >= 400 && ((retryableStatus(status) && retries 
> 0) || fallback.isPresent())) {
-                        // Release this response without accumulating the 
error body before another attempt.
+                    if (status >= 400) {
+                        final AiProxyRetryPolicy.Decision decision =
+                                RETRY_POLICY.decideStatus(status, 
retriesRemaining, fallback.isPresent());
+                        if (decision == 
AiProxyRetryPolicy.Decision.FORWARD_RESPONSE) {
+                            return consumeResponse(response, consumer, 
emitted);
+                        }
                         return response.getBody().then(Mono.error(new 
UpstreamStatusFailure(status)));
                     }
                     AiUpstreamResponse adapted = 
prepared.request().adaptResponse(prepared.provider().adaptResponse(response));
                     AiUpstreamResponse encoded = 
prepared.protocol().encodeResponse(adapted, prepared.stream());
-                    return encoded.getBody().switchOnFirst((signal, body) -> {
-                        if (signal.isOnError()) {
-                            return 
Flux.error(Objects.requireNonNull(signal.getThrowable()));
-                        }
-                        AiUpstreamResponse ready = new 
AiUpstreamResponse(encoded.getStatusCode(), encoded.getHeaders(),
-                                body.doOnNext(bytes -> emitted.set(true)));
-                        return Mono.defer(() -> consumer.apply(ready)).flux();
-                    }).then();
+                    return consumeResponse(encoded, consumer, emitted);
                 })
                 .onErrorResume(error -> {
-                    boolean transportFailure = error instanceof 
AiTransportException
-                            || error instanceof AiProxyException
-                            && ((AiProxyException) error).getKind() == 
AiProxyException.Kind.TRANSPORT;
-                    boolean statusFailure = error instanceof 
UpstreamStatusFailure;
-                    if (emitted.get() || !(transportFailure || statusFailure)) 
{
+                    final AiProxyRetryPolicy.Decision decision;
+                    if (emitted.get()) {
+                        return Mono.error(error);
+                    }
+                    if (error instanceof AiTransportException 
transportException) {
+                        decision = RETRY_POLICY.decideTransport(
+                                transportException.getKind(),
+                                retriesRemaining,
+                                fallback.isPresent());
+                    } else if (error instanceof UpstreamStatusFailure 
statusFailure) {
+                        decision = RETRY_POLICY.decideStatus(
+                                statusFailure.status,
+                                retriesRemaining,
+                                fallback.isPresent());
+                    } else {
                         return Mono.error(error);
                     }
-                    boolean retryable = transportFailure || 
retryableStatus(((UpstreamStatusFailure) error).status);
-                    if (retryable && retries > 0) {
-                        Duration delay = prepared.stream() ? Duration.ZERO : 
retryBackoff.multipliedBy(1L << (3 - retries));
-                        return Mono.delay(delay).then(attempt(requestBody, 
prepared, fallback, retries - 1, consumer, emitted));
+                    if (decision == AiProxyRetryPolicy.Decision.RETRY) {
+                        final int retryIndex = maxRetries - retriesRemaining;
+                        final Duration delay = 
retryProperties.retryDelay(prepared.stream(), retryIndex);
+                        return Mono.delay(delay).then(attempt(
+                                requestBody, prepared, fallback, maxRetries, 
retriesRemaining - 1, consumer, emitted));
                     }
-                    if (fallback.isPresent()) {
-                        return Mono.defer(() -> attempt(requestBody, 
prepare(requestBody, fallback.get()), Optional.empty(), 0, consumer, emitted));
+                    if (decision == AiProxyRetryPolicy.Decision.FALLBACK) {
+                        return Mono.defer(() -> attempt(
+                                requestBody, prepare(requestBody, 
fallback.get()), Optional.empty(), 0, 0, consumer, emitted));
                     }
                     return Mono.error(error);
                 });
     }
 
-    private boolean retryableStatus(final int status) {
-        return status == 429 || (status >= 500 && status < 600);
+    private Mono<Void> consumeResponse(
+            final AiUpstreamResponse response,
+            final Function<AiUpstreamResponse, Mono<Void>> consumer,
+            final AtomicBoolean emitted) {
+        return response.getBody()
+                .switchOnFirst((signal, body) -> {
+                    if (signal.isOnError()) {
+                        return Flux.error(
+                                Objects.requireNonNull(
+                                        signal.getThrowable()));
+                    }
+
+                    final AiUpstreamResponse ready =
+                            new AiUpstreamResponse(
+                                    response.getStatusCode(),
+                                    response.getHeaders(),
+                                    body.doOnNext(
+                                            bytes -> emitted.set(true)));
+
+                    return Mono.defer(
+                            () -> consumer.apply(ready)).flux();
+                })
+                .then();
     }
 
     private PreparedExchange prepare(final String requestBody, final 
AiCommonConfig config) {
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicy.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicy.java
new file mode 100644
index 0000000000..cde94f1387
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicy.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.proxy.enhanced.service;
+
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
+
+/**
+ * Decides whether an AI upstream failure should be retried, switched to 
fallback,
+ * forwarded to the client, or propagated.
+ */
+final class AiProxyRetryPolicy {
+
+    Decision decideStatus(
+            final int status,
+            final int retriesRemaining,
+            final boolean fallbackAvailable) {
+        if (!retryableStatus(status)) {
+            return Decision.FORWARD_RESPONSE;
+        }
+        if (retriesRemaining > 0) {
+            return Decision.RETRY;
+        }
+        if (fallbackAvailable) {
+            return Decision.FALLBACK;
+        }
+        return Decision.FORWARD_RESPONSE;
+    }
+
+    Decision decideTransport(
+            final AiTransportException.Kind kind,
+            final int retriesRemaining,
+            final boolean fallbackAvailable) {
+        if (retriesRemaining > 0 && retryableTransportFailure(kind)) {
+            return Decision.RETRY;
+        }
+        if (fallbackAvailable && fallbackEligibleTransportFailure(kind)) {
+            return Decision.FALLBACK;
+        }
+        return Decision.PROPAGATE_ERROR;
+    }
+
+    private boolean retryableStatus(final int status) {
+        return status == 429 || status >= 500 && status < 600;
+    }
+
+    private boolean retryableTransportFailure(final AiTransportException.Kind 
kind) {
+        return switch (kind) {
+            case CONNECT_TIMEOUT,
+                 RESPONSE_HEADER_TIMEOUT,
+                 STREAM_IDLE_TIMEOUT,
+                 CONNECTION_RESET,
+                 IO_FAILURE -> true;
+            case STREAM_DURATION_TIMEOUT,
+                 RESPONSE_TOO_LARGE,
+                 DNS_FAILURE,
+                 TLS_FAILURE,
+                 UNKNOWN -> false;
+        };
+    }
+
+    private boolean fallbackEligibleTransportFailure(final 
AiTransportException.Kind kind) {
+        return switch (kind) {
+            case CONNECT_TIMEOUT,
+                 RESPONSE_HEADER_TIMEOUT,
+                 RESPONSE_TOO_LARGE,
+                 CONNECTION_RESET,
+                 IO_FAILURE -> true;
+            case STREAM_IDLE_TIMEOUT,
+                 STREAM_DURATION_TIMEOUT,
+                 DNS_FAILURE,
+                 TLS_FAILURE,
+                 UNKNOWN -> false;
+        };
+    }
+
+    enum Decision {
+        RETRY,
+        FALLBACK,
+        FORWARD_RESPONSE,
+        PROPAGATE_ERROR
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryProperties.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryProperties.java
new file mode 100644
index 0000000000..5f1cdc7b6d
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryProperties.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.proxy.enhanced.service;
+
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Retry properties and backoff calculation for AI upstream requests.
+ */
+public final class AiProxyRetryProperties {
+
+    private static final int MAX_EXPONENTIAL_RETRIES = 30;
+
+    private int maxRetries = 3;
+
+    private int streamingMaxRetries = 1;
+
+    private Duration initialBackoff = Duration.ofSeconds(1);
+
+    /**
+     * Create retry properties with defaults.
+     */
+    public AiProxyRetryProperties() {
+    }
+
+    /**
+     * Create retry properties.
+     *
+     * @param maxRetries maximum retries for non-streaming requests
+     * @param streamingMaxRetries maximum retries before a streaming response 
emits data
+     * @param initialBackoff initial backoff for non-streaming retries
+     */
+    public AiProxyRetryProperties(final int maxRetries, final int 
streamingMaxRetries, final Duration initialBackoff) {
+        setMaxRetries(maxRetries);
+        setStreamingMaxRetries(streamingMaxRetries);
+        setInitialBackoff(initialBackoff);
+    }
+
+    /**
+     * Get maximum retries for non-streaming requests.
+     *
+     * @return maximum retries
+     */
+    public int getMaxRetries() {
+        return maxRetries;
+    }
+
+    /**
+     * Set maximum retries for non-streaming requests.
+     *
+     * @param maxRetries maximum retries
+     */
+    public void setMaxRetries(final int maxRetries) {
+        this.maxRetries = requireRetryCount(maxRetries, "maxRetries");
+    }
+
+    /**
+     * Get maximum retries before a streaming response emits data.
+     *
+     * @return maximum streaming retries
+     */
+    public int getStreamingMaxRetries() {
+        return streamingMaxRetries;
+    }
+
+    /**
+     * Set maximum retries before a streaming response emits data.
+     *
+     * @param streamingMaxRetries maximum streaming retries
+     */
+    public void setStreamingMaxRetries(final int streamingMaxRetries) {
+        this.streamingMaxRetries = requireRetryCount(streamingMaxRetries, 
"streamingMaxRetries");
+    }
+
+    /**
+     * Get initial non-streaming retry backoff.
+     *
+     * @return initial retry backoff
+     */
+    public Duration getInitialBackoff() {
+        return initialBackoff;
+    }
+
+    /**
+     * Set initial non-streaming retry backoff.
+     *
+     * @param initialBackoff initial retry backoff
+     */
+    public void setInitialBackoff(final Duration initialBackoff) {
+        if (Objects.isNull(initialBackoff) || initialBackoff.isNegative()) {
+            throw new IllegalArgumentException("initialBackoff must not be 
null or negative");
+        }
+        this.initialBackoff = initialBackoff;
+    }
+
+    int maxRetries(final boolean stream) {
+        return stream ? streamingMaxRetries : maxRetries;
+    }
+
+    Duration retryDelay(final boolean stream, final int retryIndex) {
+        if (retryIndex < 0 || retryIndex >= MAX_EXPONENTIAL_RETRIES) {
+            throw new IllegalArgumentException(
+                    "retryIndex must be between 0 and " + 
(MAX_EXPONENTIAL_RETRIES - 1));
+        }
+        if (stream) {
+            return Duration.ZERO;
+        }
+        return initialBackoff.multipliedBy(1L << retryIndex);
+    }
+
+    private int requireRetryCount(final int value, final String name) {
+        if (value < 0 || value > MAX_EXPONENTIAL_RETRIES) {
+            throw new IllegalArgumentException(
+                    name + " must be between 0 and " + 
MAX_EXPONENTIAL_RETRIES);
+        }
+        return value;
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
index a18ff12a23..4431de85c7 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
@@ -146,11 +146,11 @@ class AiProxyPluginTest {
     @Test
     void testExecuteWithAdminFallback() {
         handle.setFallbackConfig(fallback("admin"));
-        AiProxyPlugin plugin = plugin(request -> 
Mono.just(request.getUri().getHost().equals("main.example") ? response(400, 
"bad") : response(202, "admin")));
+        AiProxyPlugin plugin = plugin(request -> 
Mono.just(request.getUri().getHost().equals("main.example") ? response(500, 
"bad") : response(202, "admin")));
         StepVerifier.create(execute(plugin)).verifyComplete();
-        assertEquals(2, requests.size());
-        assertEquals("admin.example", requests.get(1).getUri().getHost());
-        assertEquals(List.of("Bearer admin-key"), 
requests.get(1).getHeaders().get("Authorization"));
+        assertEquals(5, requests.size());
+        assertEquals("admin.example", requests.get(4).getUri().getHost());
+        assertEquals(List.of("Bearer admin-key"), 
requests.get(4).getHeaders().get("Authorization"));
         assertEquals(HttpStatus.ACCEPTED, 
exchange.getResponse().getStatusCode());
     }
 
@@ -308,6 +308,6 @@ class AiProxyPluginTest {
     }
 
     private AiTransportException failure() {
-        return new AiTransportException(AiTransportException.Kind.UNKNOWN, 
"fixture failure", null);
+        return new AiTransportException(AiTransportException.Kind.IO_FAILURE, 
"fixture failure", null);
     }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java
index b6746a82ca..ee485ee542 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java
@@ -32,7 +32,11 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactor
 import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAi;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAiCompatible;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
@@ -49,6 +53,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -121,6 +126,54 @@ class AiProxyEngineOrchestrationTest {
         assertEquals(List.of("upstream unavailable"), received);
     }
 
+    @Test
+    void configuredRetryCountsControlPrimaryAttempts() {
+        AtomicInteger nonStreamingCalls = new AtomicInteger();
+        AiProxyEngine nonStreamingEngine = engine(request -> {
+            nonStreamingCalls.incrementAndGet();
+            return Mono.just(response(503, Flux.just(bytes("unavailable"))));
+        }, new AiProxyRetryProperties(2, 0, Duration.ZERO));
+
+        StepVerifier.create(nonStreamingEngine.execute(BODY, config(false, 
"main"), Optional.empty(),
+                response -> response.getBody().then())).verifyComplete();
+        assertEquals(3, nonStreamingCalls.get());
+
+        AtomicInteger streamingCalls = new AtomicInteger();
+        AiProxyEngine streamingEngine = engine(request -> {
+            streamingCalls.incrementAndGet();
+            return Mono.error(networkFailure());
+        }, new AiProxyRetryProperties(2, 0, Duration.ZERO));
+
+        StepVerifier.create(streamingEngine.execute(BODY, config(true, 
"main"), Optional.empty(),
+                response -> 
response.getBody().then())).expectError(AiTransportException.class).verify();
+        assertEquals(1, streamingCalls.get());
+    }
+
+    @Test
+    void nonStreamingRetriesUseExponentialBackoff() {
+        AtomicInteger calls = new AtomicInteger();
+        AiProxyEngine engine = engine(request -> {
+            int attempt = calls.incrementAndGet();
+            return Mono.just(attempt < 4
+                    ? response(503, Flux.just(bytes("unavailable")))
+                    : response(200, Flux.just(bytes("ok"))));
+        }, new AiProxyRetryProperties(3, 1, Duration.ofSeconds(1)));
+
+        StepVerifier.withVirtualTime(() -> engine.execute(BODY, config(false, 
"main"), Optional.empty(),
+                        response -> response.getBody().then()))
+                .expectSubscription()
+                .then(() -> assertEquals(1, calls.get()))
+                .thenAwait(Duration.ofMillis(999))
+                .then(() -> assertEquals(1, calls.get()))
+                .thenAwait(Duration.ofMillis(1))
+                .then(() -> assertEquals(2, calls.get()))
+                .thenAwait(Duration.ofSeconds(2))
+                .then(() -> assertEquals(3, calls.get()))
+                .thenAwait(Duration.ofSeconds(4))
+                .then(() -> assertEquals(4, calls.get()))
+                .verifyComplete();
+    }
+
     @Test
     void 
streamingRetriesOnceAndRebuildsFallbackUsingItsProviderConfiguration() {
         List<AiUpstreamRequest> requests = new ArrayList<>();
@@ -150,18 +203,48 @@ class AiProxyEngineOrchestrationTest {
         assertEquals(List.of("data: {\"vendor\":true}\n\n", "data: 
[DONE]\n\n"), received);
     }
 
-    @Test
-    void clientErrorsAreForwardedWithoutRetryAndMayUseConfiguredFallback() {
+    @ParameterizedTest
+    @ValueSource(ints = {400, 401, 403, 404, 408, 422})
+    void clientErrorsAreForwardedWithoutRetryOrFallback(final int status) {
         AtomicInteger calls = new AtomicInteger();
+        List<String> received = new ArrayList<>();
         AiProxyEngine engine = engine(request -> {
             calls.incrementAndGet();
-            return Mono.just(response(401, Flux.just(bytes("unauthorized"))));
+            return Mono.just(response(status, Flux.just(bytes("client 
error"))));
         });
-        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.empty(), response -> response.getBody().then())).verifyComplete();
+
+        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.of(config(false, "fallback")), response -> {
+            assertEquals(status, response.getStatusCode());
+            assertEquals(List.of("kept"), 
response.getHeaders().get("X-Upstream"));
+            return consume(response, received);
+        })).verifyComplete();
+
         assertEquals(1, calls.get());
-        calls.set(0);
-        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.of(config(false, "fallback")), response -> 
response.getBody().then())).verifyComplete();
-        assertEquals(2, calls.get());
+        assertEquals(List.of("client error"), received);
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {429, 500, 502, 503, 504, 599})
+    void retryableStatusesRetryBeforeUsingFallback(final int status) {
+        AtomicInteger primaryCalls = new AtomicInteger();
+        AtomicInteger fallbackCalls = new AtomicInteger();
+        List<String> received = new ArrayList<>();
+        AiProxyEngine engine = engine(request -> {
+            String requestBody = new String(request.getBody(), 
StandardCharsets.UTF_8);
+            if (requestBody.contains("\"model\":\"fallback\"")) {
+                fallbackCalls.incrementAndGet();
+                return Mono.just(response(200, Flux.just(bytes("fallback 
response"))));
+            }
+            primaryCalls.incrementAndGet();
+            return Mono.just(response(status, Flux.just(bytes("retryable 
error"))));
+        });
+
+        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.of(config(false, "fallback")),
+                response -> consume(response, received))).verifyComplete();
+
+        assertEquals(4, primaryCalls.get());
+        assertEquals(1, fallbackCalls.get());
+        assertEquals(List.of("fallback response"), received);
     }
 
     @Test
@@ -175,6 +258,112 @@ class AiProxyEngineOrchestrationTest {
         assertEquals(List.of("data: {\"ok\":true}\n\n", "data: [DONE]\n\n"), 
received);
     }
 
+    @ParameterizedTest
+    @CsvSource({
+        "CONNECT_TIMEOUT, 2",
+        "RESPONSE_HEADER_TIMEOUT, 2",
+        "STREAM_IDLE_TIMEOUT, 2",
+        "STREAM_DURATION_TIMEOUT, 1",
+        "RESPONSE_TOO_LARGE, 1",
+        "DNS_FAILURE, 1",
+        "TLS_FAILURE, 1",
+        "CONNECTION_RESET, 2",
+        "IO_FAILURE, 2",
+        "UNKNOWN, 1"
+    })
+    void transportFailuresFollowTheRetryWhitelist(final 
AiTransportException.Kind kind, final int expectedCalls) {
+        AtomicInteger calls = new AtomicInteger();
+        AiProxyEngine engine = engine(request -> {
+            calls.incrementAndGet();
+            return Mono.error(transportFailure(kind));
+        });
+
+        StepVerifier.create(engine.execute(BODY, config(true, "main"), 
Optional.empty(), response -> response.getBody().then()))
+                .expectErrorSatisfies(error -> assertEquals(kind,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+
+        assertEquals(expectedCalls, calls.get());
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "CONNECT_TIMEOUT, 2, true",
+        "RESPONSE_HEADER_TIMEOUT, 2, true",
+        "STREAM_IDLE_TIMEOUT, 2, false",
+        "STREAM_DURATION_TIMEOUT, 1, false",
+        "RESPONSE_TOO_LARGE, 1, true",
+        "DNS_FAILURE, 1, false",
+        "TLS_FAILURE, 1, false",
+        "CONNECTION_RESET, 2, true",
+        "IO_FAILURE, 2, true",
+        "UNKNOWN, 1, false"
+    })
+    void transportFailuresFollowTheFallbackWhitelist(final 
AiTransportException.Kind kind,
+                                                      final int 
expectedPrimaryCalls,
+                                                      final boolean 
fallbackExpected) {
+        AtomicInteger primaryCalls = new AtomicInteger();
+        AtomicInteger fallbackCalls = new AtomicInteger();
+        AiProxyEngine engine = engine(request -> {
+            String requestBody = new String(request.getBody(), 
StandardCharsets.UTF_8);
+            if (requestBody.contains("\"model\":\"fallback\"")) {
+                fallbackCalls.incrementAndGet();
+                return Mono.just(response(200, Flux.just(bytes("fallback 
response"))));
+            }
+            primaryCalls.incrementAndGet();
+            return Mono.error(transportFailure(kind));
+        });
+
+        Mono<Void> execution = engine.execute(BODY, config(true, "main"), 
Optional.of(config(true, "fallback")),
+                response -> response.getBody().then());
+        if (fallbackExpected) {
+            StepVerifier.create(execution).verifyComplete();
+        } else {
+            StepVerifier.create(execution)
+                    .expectErrorSatisfies(error -> assertEquals(kind,
+                            assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                    .verify();
+        }
+
+        assertEquals(expectedPrimaryCalls, primaryCalls.get());
+        assertEquals(fallbackExpected ? 1 : 0, fallbackCalls.get());
+    }
+
+    @Test
+    void responseTooLargeDoesNotRetryTheSameProvider() {
+        AtomicInteger calls = new AtomicInteger();
+        AiProxyEngine engine = engine(request -> {
+            calls.incrementAndGet();
+            return Mono.error(responseTooLargeFailure());
+        });
+
+        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.empty(), response -> response.getBody().then()))
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.RESPONSE_TOO_LARGE,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+        assertEquals(1, calls.get());
+    }
+
+    @Test
+    void responseTooLargeCanUseConfiguredFallback() {
+        AtomicInteger calls = new AtomicInteger();
+        AtomicBoolean fallbackUsed = new AtomicBoolean();
+        AiProxyEngine engine = engine(request -> {
+            calls.incrementAndGet();
+            String requestBody = new String(request.getBody(), 
StandardCharsets.UTF_8);
+            if (!requestBody.contains("\"model\":\"fallback\"")) {
+                return Mono.error(responseTooLargeFailure());
+            }
+            fallbackUsed.set(true);
+            return Mono.just(response(200, Flux.just(bytes("fallback 
response"))));
+        });
+
+        StepVerifier.create(engine.execute(BODY, config(false, "main"), 
Optional.of(config(false, "fallback")),
+                response -> response.getBody().then())).verifyComplete();
+        assertEquals(2, calls.get());
+        assertTrue(fallbackUsed.get());
+    }
+
     @Test
     void neverRetriesOrFallsBackAfterBodyEmission() {
         AtomicInteger calls = new AtomicInteger();
@@ -184,7 +373,7 @@ class AiProxyEngineOrchestrationTest {
             return Mono.just(response(200, Flux.just(bytes("data: 
{\"first\":true}\n\n")).concatWith(Flux.error(networkFailure()))));
         });
         StepVerifier.create(engine.execute(BODY, config(true, "main"), 
Optional.of(config(true, "fallback")), response -> consume(response, received)))
-                .expectError(AiProxyException.class).verify();
+                .expectError(AiTransportException.class).verify();
         assertEquals(1, calls.get());
         assertEquals(List.of("data: {\"first\":true}\n\n"), received);
     }
@@ -197,7 +386,7 @@ class AiProxyEngineOrchestrationTest {
             return Mono.error(networkFailure());
         });
         StepVerifier.create(engine.execute(BODY, config(true, "main"), 
Optional.of(config(true, "fallback")), response -> response.getBody().then()))
-                .expectError(AiProxyException.class).verify();
+                .expectError(AiTransportException.class).verify();
         assertEquals(3, calls.get());
     }
 
@@ -283,6 +472,11 @@ class AiProxyEngineOrchestrationTest {
                 new AiProxyProviderFactory(List.of(new OpenAi(), new 
OpenAiCompatible("DeepSeek"))), transport, Duration.ZERO);
     }
 
+    private AiProxyEngine engine(final AiProxyTransport transport, final 
AiProxyRetryProperties retryProperties) {
+        return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new 
OpenAiChat())),
+                new AiProxyProviderFactory(List.of(new OpenAi(), new 
OpenAiCompatible("DeepSeek"))), transport, retryProperties);
+    }
+
     private AiCommonConfig config(final boolean stream, final String model) {
         AiCommonConfig config = new AiCommonConfig();
         config.setProvider("OpenAI");
@@ -305,7 +499,15 @@ class AiProxyEngineOrchestrationTest {
         return value.getBytes(StandardCharsets.UTF_8);
     }
 
-    private AiProxyException networkFailure() {
-        return new AiProxyException(AiProxyException.Kind.TRANSPORT, "test 
failure", null);
+    private AiTransportException networkFailure() {
+        return transportFailure(AiTransportException.Kind.IO_FAILURE);
+    }
+
+    private AiTransportException responseTooLargeFailure() {
+        return transportFailure(AiTransportException.Kind.RESPONSE_TOO_LARGE);
+    }
+
+    private AiTransportException transportFailure(final 
AiTransportException.Kind kind) {
+        return new AiTransportException(kind, "test transport failure: " + 
kind, null);
     }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
index 7efece125c..cdcc7c502c 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
@@ -33,6 +33,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAi;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -212,7 +213,7 @@ class AiProxyEngineTest {
         engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new 
OpenAiChat())),
                 new AiProxyProviderFactory(List.of(new OpenAi(), fallback)), 
request -> {
                     transports.incrementAndGet();
-                    return Mono.error(new 
AiProxyException(AiProxyException.Kind.TRANSPORT, "unavailable", null));
+                    return Mono.error(new 
AiTransportException(AiTransportException.Kind.IO_FAILURE, "unavailable", 
null));
                 }, Duration.ZERO);
         config.setStream(true);
         final AiCommonConfig fallbackConfig = new AiCommonConfig(config);
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicyTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicyTest.java
new file mode 100644
index 0000000000..9933af48ec
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPolicyTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.proxy.enhanced.service;
+
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Test cases for {@link AiProxyRetryPolicy}.
+ */
+class AiProxyRetryPolicyTest {
+
+    private final AiProxyRetryPolicy policy = new AiProxyRetryPolicy();
+
+    @ParameterizedTest
+    @CsvSource({
+        "400, 3, true, FORWARD_RESPONSE",
+        "401, 3, true, FORWARD_RESPONSE",
+        "408, 3, true, FORWARD_RESPONSE",
+        "422, 3, true, FORWARD_RESPONSE",
+        "429, 1, true, RETRY",
+        "429, 0, true, FALLBACK",
+        "429, 0, false, FORWARD_RESPONSE",
+        "500, 1, true, RETRY",
+        "503, 0, true, FALLBACK",
+        "599, 0, false, FORWARD_RESPONSE",
+        "600, 3, true, FORWARD_RESPONSE"
+    })
+    void shouldDecideStatusFailure(final int status,
+                                   final int retriesRemaining,
+                                   final boolean fallbackAvailable,
+                                   final AiProxyRetryPolicy.Decision expected) 
{
+        assertEquals(expected, policy.decideStatus(status, retriesRemaining, 
fallbackAvailable));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "CONNECT_TIMEOUT, RETRY",
+        "RESPONSE_HEADER_TIMEOUT, RETRY",
+        "STREAM_IDLE_TIMEOUT, RETRY",
+        "STREAM_DURATION_TIMEOUT, PROPAGATE_ERROR",
+        "RESPONSE_TOO_LARGE, FALLBACK",
+        "DNS_FAILURE, PROPAGATE_ERROR",
+        "TLS_FAILURE, PROPAGATE_ERROR",
+        "CONNECTION_RESET, RETRY",
+        "IO_FAILURE, RETRY",
+        "UNKNOWN, PROPAGATE_ERROR"
+    })
+    void shouldRetryBeforeUsingFallback(final AiTransportException.Kind kind,
+                                        final AiProxyRetryPolicy.Decision 
expected) {
+        assertEquals(expected, policy.decideTransport(kind, 1, true));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "CONNECT_TIMEOUT, FALLBACK",
+        "RESPONSE_HEADER_TIMEOUT, FALLBACK",
+        "STREAM_IDLE_TIMEOUT, PROPAGATE_ERROR",
+        "STREAM_DURATION_TIMEOUT, PROPAGATE_ERROR",
+        "RESPONSE_TOO_LARGE, FALLBACK",
+        "DNS_FAILURE, PROPAGATE_ERROR",
+        "TLS_FAILURE, PROPAGATE_ERROR",
+        "CONNECTION_RESET, FALLBACK",
+        "IO_FAILURE, FALLBACK",
+        "UNKNOWN, PROPAGATE_ERROR"
+    })
+    void shouldApplyFallbackWhitelistAfterRetriesAreExhausted(final 
AiTransportException.Kind kind,
+                                                              final 
AiProxyRetryPolicy.Decision expected) {
+        assertEquals(expected, policy.decideTransport(kind, 0, true));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "CONNECT_TIMEOUT",
+        "RESPONSE_HEADER_TIMEOUT",
+        "STREAM_IDLE_TIMEOUT",
+        "STREAM_DURATION_TIMEOUT",
+        "RESPONSE_TOO_LARGE",
+        "DNS_FAILURE",
+        "TLS_FAILURE",
+        "CONNECTION_RESET",
+        "IO_FAILURE",
+        "UNKNOWN"
+    })
+    void shouldPropagateTransportFailureWithoutRetriesOrFallback(final 
AiTransportException.Kind kind) {
+        assertEquals(AiProxyRetryPolicy.Decision.PROPAGATE_ERROR,
+                policy.decideTransport(kind, 0, false));
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPropertiesTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPropertiesTest.java
new file mode 100644
index 0000000000..c85ea36f92
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyRetryPropertiesTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.proxy.enhanced.service;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Test cases for {@link AiProxyRetryProperties}.
+ */
+class AiProxyRetryPropertiesTest {
+
+    @Test
+    void shouldProvideCompatibleDefaults() {
+        final AiProxyRetryProperties properties = new AiProxyRetryProperties();
+
+        assertEquals(3, properties.maxRetries(false));
+        assertEquals(1, properties.maxRetries(true));
+        assertEquals(Duration.ofSeconds(1), properties.getInitialBackoff());
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "0, 1000",
+        "1, 2000",
+        "2, 4000"
+    })
+    void shouldCalculateExponentialBackoff(final int retryIndex, final long 
expectedMillis) {
+        final AiProxyRetryProperties properties = new 
AiProxyRetryProperties(3, 1, Duration.ofSeconds(1));
+
+        assertEquals(Duration.ofMillis(expectedMillis), 
properties.retryDelay(false, retryIndex));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"0", "1", "29"})
+    void shouldRetryStreamingRequestsImmediately(final int retryIndex) {
+        final AiProxyRetryProperties properties = new 
AiProxyRetryProperties(3, 1, Duration.ofSeconds(1));
+
+        assertEquals(Duration.ZERO, properties.retryDelay(true, retryIndex));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "-1, 1",
+        "31, 1",
+        "3, -1",
+        "3, 31"
+    })
+    void shouldRejectRetryCountsOutsideTheSupportedRange(final int maxRetries, 
final int streamingMaxRetries) {
+        assertThrows(IllegalArgumentException.class,
+                () -> new AiProxyRetryProperties(maxRetries, 
streamingMaxRetries, Duration.ZERO));
+    }
+
+    @Test
+    void shouldRejectInvalidBackoff() {
+        assertThrows(IllegalArgumentException.class, () -> new 
AiProxyRetryProperties(3, 1, null));
+        assertThrows(IllegalArgumentException.class,
+                () -> new AiProxyRetryProperties(3, 1, Duration.ofMillis(-1)));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"-1", "30"})
+    void shouldRejectRetryIndexOutsideTheSupportedRange(final int retryIndex) {
+        final AiProxyRetryProperties properties = new AiProxyRetryProperties();
+
+        assertThrows(IllegalArgumentException.class, () -> 
properties.retryDelay(false, retryIndex));
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
index a80f1dd00c..d84e90d965 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
@@ -30,6 +30,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAi;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAiCompatible;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyConfigService;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyRetryProperties;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.subscriber.CommonAiProxyApiKeyDataSubscriber;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransportProperties;
@@ -107,12 +108,15 @@ public class AiProxyPluginConfiguration {
      * @param aiProxyProtocolFactory protocol factory
      * @param aiProxyProviderFactory provider factory
      * @param aiProxyTransport       transport
+     * @param retryProperties        retry properties
      * @return the AI proxy engine
      */
     @Bean
     public AiProxyEngine aiProxyEngine(final AiProxyProtocolFactory 
aiProxyProtocolFactory,
-                                       final AiProxyProviderFactory 
aiProxyProviderFactory, final AiProxyTransport aiProxyTransport) {
-        return new AiProxyEngine(aiProxyProtocolFactory, 
aiProxyProviderFactory, aiProxyTransport);
+                                       final AiProxyProviderFactory 
aiProxyProviderFactory,
+                                       final AiProxyTransport aiProxyTransport,
+                                       final AiProxyRetryProperties 
retryProperties) {
+        return new AiProxyEngine(aiProxyProtocolFactory, 
aiProxyProviderFactory, aiProxyTransport, retryProperties);
     }
 
     /**
@@ -244,6 +248,17 @@ public class AiProxyPluginConfiguration {
         return new AiProxyTransportProperties();
     }
 
+    /**
+     * AI proxy retry properties.
+     *
+     * @return AI proxy retry properties
+     */
+    @Bean
+    @ConfigurationProperties(prefix = "shenyu.plugins.ai.proxy.retry")
+    public AiProxyRetryProperties aiProxyRetryProperties() {
+        return new AiProxyRetryProperties();
+    }
+
     HttpClient createHttpClient(final AiProxyTransportProperties properties) {
         return HttpClient.create()
                 .option(
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
index db54707ecf..271edb3cf6 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
@@ -32,6 +32,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactor
 import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyRetryProperties;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransportProperties;
 import org.junit.jupiter.api.Test;
@@ -128,6 +129,45 @@ class AiProxyPluginConfigurationTest {
                 });
     }
 
+    @Test
+    void shouldUseDefaultRetryProperties() {
+        contextRunner.run(context -> {
+            final AiProxyRetryProperties properties = 
context.getBean(AiProxyRetryProperties.class);
+            assertEquals(3, properties.getMaxRetries());
+            assertEquals(1, properties.getStreamingMaxRetries());
+            assertEquals(Duration.ofSeconds(1), 
properties.getInitialBackoff());
+        });
+    }
+
+    @Test
+    void shouldBindConfiguredRetryProperties() {
+        contextRunner
+                .withPropertyValues(
+                        "shenyu.plugins.ai.proxy.retry.max-retries=2",
+                        
"shenyu.plugins.ai.proxy.retry.streaming-max-retries=0",
+                        "shenyu.plugins.ai.proxy.retry.initial-backoff=500ms")
+                .run(context -> {
+                    final AiProxyRetryProperties properties = 
context.getBean(AiProxyRetryProperties.class);
+                    assertEquals(2, properties.getMaxRetries());
+                    assertEquals(0, properties.getStreamingMaxRetries());
+                    assertEquals(Duration.ofMillis(500), 
properties.getInitialBackoff());
+                });
+    }
+
+    @Test
+    void shouldRejectInvalidRetryProperties() {
+        for (String property : List.of(
+                "max-retries=-1",
+                "max-retries=31",
+                "streaming-max-retries=-1",
+                "streaming-max-retries=31",
+                "initial-backoff=-1s")) {
+            contextRunner
+                    .withPropertyValues("shenyu.plugins.ai.proxy.retry." + 
property)
+                    .run(context -> 
org.assertj.core.api.Assertions.assertThat(context).hasFailed());
+        }
+    }
+
     @Test
     void shouldConvertConnectTimeoutToNettyMilliseconds() {
         final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();

Reply via email to