diff --git a/gravitee-apim-console-webui/src/management/api/endpoints-v4/llm-provider/api-llm-provider.component.html b/gravitee-apim-console-webui/src/management/api/endpoints-v4/llm-provider/api-llm-provider.component.html
index edca47f5ca9..1f959567259 100644
--- a/gravitee-apim-console-webui/src/management/api/endpoints-v4/llm-provider/api-llm-provider.component.html
+++ b/gravitee-apim-console-webui/src/management/api/endpoints-v4/llm-provider/api-llm-provider.component.html
@@ -44,7 +44,7 @@
@if (showConfiguration && providerSchema?.config) {
- @if (formGroup.errors?.providerMismatch; as mismatch) {
+ @if (formGroup.dirty && formGroup.errors?.providerMismatch; as mismatch) {
All endpoints in this group must use the same provider ({{ mismatch.expected }}).
}
diff --git a/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.spec.ts b/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.spec.ts
index 554e3441a42..af362b41075 100644
--- a/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.spec.ts
+++ b/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.spec.ts
@@ -69,7 +69,6 @@ describe('ApiV4FailoverComponent', () => {
it('should enable and set failover config', async () => {
const api = fakeApiV4({
id: API_ID,
- failover: undefined,
});
expectApiGetRequest(api);
const saveBar = await loader.getHarness(GioSaveBarHarness);
@@ -127,7 +126,7 @@ describe('ApiV4FailoverComponent', () => {
enabled: true,
forceNextEndpointOnFailure: false,
maxRetries: 2,
- failureCondition: undefined,
+ failureCondition: '',
slowCallDuration: 200,
openStateDuration: 2000,
maxFailures: 2,
@@ -221,7 +220,7 @@ describe('ApiV4FailoverComponent', () => {
enabled: true,
forceNextEndpointOnFailure: false,
maxRetries: 3,
- failureCondition: undefined,
+ failureCondition: '',
slowCallDuration: 300,
openStateDuration: 3000,
maxFailures: 3,
diff --git a/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.ts b/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.ts
index 2dabdce7783..146c7fc63d3 100644
--- a/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.ts
+++ b/gravitee-apim-console-webui/src/management/api/failover-v4/api-failover-v4.component.ts
@@ -198,7 +198,7 @@ export class ApiFailoverV4Component implements OnInit, OnDestroy {
enabled,
forceNextEndpointOnFailure,
maxRetries,
- failureCondition: failureCondition || undefined,
+ failureCondition,
slowCallDuration,
openStateDuration,
maxFailures,
diff --git a/gravitee-apim-gateway/gravitee-apim-gateway-core/src/main/java/io/gravitee/gateway/reactive/core/failover/FailoverInvoker.java b/gravitee-apim-gateway/gravitee-apim-gateway-core/src/main/java/io/gravitee/gateway/reactive/core/failover/FailoverInvoker.java
index b20258c1c86..5d0eb0bc637 100644
--- a/gravitee-apim-gateway/gravitee-apim-gateway-core/src/main/java/io/gravitee/gateway/reactive/core/failover/FailoverInvoker.java
+++ b/gravitee-apim-gateway/gravitee-apim-gateway-core/src/main/java/io/gravitee/gateway/reactive/core/failover/FailoverInvoker.java
@@ -25,12 +25,15 @@
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.rxjava3.circuitbreaker.operator.CircuitBreakerOperator;
import io.gravitee.definition.model.v4.failover.Failover;
+import io.gravitee.gateway.api.buffer.Buffer;
+import io.gravitee.gateway.api.http.HttpHeaders;
import io.gravitee.gateway.reactive.api.ExecutionFailure;
import io.gravitee.gateway.reactive.api.context.ContextAttributes;
import io.gravitee.gateway.reactive.api.context.ExecutionContext;
import io.gravitee.gateway.reactive.api.context.http.HttpExecutionContext;
import io.gravitee.gateway.reactive.api.invoker.HttpInvoker;
import io.gravitee.gateway.reactive.api.invoker.Invoker;
+import io.gravitee.gateway.reactive.core.context.HttpRequestInternal;
import io.gravitee.gateway.reactive.core.v4.endpoint.EndpointManager;
import io.gravitee.gateway.reactive.core.v4.endpoint.ManagedEndpoint;
import io.reactivex.rxjava3.core.Completable;
@@ -111,30 +114,33 @@ public Completable invoke(HttpExecutionContext ctx) {
final AtomicInteger totalAttempts = new AtomicInteger(0);
final AtomicReference> endpointRotation = new AtomicReference<>();
final AtomicReference firstFailedEndpoint = new AtomicReference<>();
+ final AtomicReference snapshotRef = new AtomicReference<>();
- return Completable.defer(() -> {
- int attempt = totalAttempts.getAndIncrement();
+ return captureRequestState(ctx, snapshotRef).andThen(
+ Completable.defer(() -> {
+ int attempt = totalAttempts.getAndIncrement();
- // On retry, capture the endpoint that failed in the previous attempt
- if (attempt > 0) {
- firstFailedEndpoint.compareAndSet(null, resolveCurrentEndpointName(ctx));
- }
+ // On retry, capture the endpoint that failed in the previous attempt and restore the request to its initial state
+ if (attempt > 0) {
+ firstFailedEndpoint.compareAndSet(null, resolveCurrentEndpointName(ctx));
+ restoreRequestState(ctx, snapshotRef.get());
+ }
+
+ // EndpointInvoker overrides the request endpoint. We need to set it back to original state to retry properly
+ ctx.setAttribute(ATTR_REQUEST_ENDPOINT, originalEndpoint);
+ // Entrypoint connectors skip response handling if there is an error. In the case of a retry, we need to reset the failure.
+ ctx.removeInternalAttribute(ATTR_INTERNAL_EXECUTION_FAILURE);
+
+ forceNextEndpoint(ctx, attempt, endpointRotation);
- // EndpointInvoker overrides the request endpoint. We need to set it back to original state to retry properly
- ctx.setAttribute(ATTR_REQUEST_ENDPOINT, originalEndpoint);
- // Entrypoint connectors skip response handling if there is an error. In the case of a retry, we need to reset the failure.
- ctx.removeInternalAttribute(ATTR_INTERNAL_EXECUTION_FAILURE);
-
- forceNextEndpoint(ctx, attempt, endpointRotation);
-
- // Consume body and ignore it. Consuming it with .body() method internally enables caching of chunks, which is mandatory to retry the request in case of failure.
- return ctx.request().body().ignoreElement().andThen(delegate.invoke(ctx)).andThen(evaluateFailureCondition(ctx));
- })
- .timeout(failoverConfiguration.getSlowCallDuration(), TimeUnit.MILLISECONDS)
- .retry(failoverConfiguration.getMaxRetries())
- .compose(CircuitBreakerOperator.of(circuitBreaker(ctx)))
- .onErrorResumeNext(t -> ctx.interruptWith(new ExecutionFailure(502).cause(t)))
- .doFinally(() -> recordFailoverMetrics(ctx, totalAttempts.get(), firstFailedEndpoint.get()));
+ return delegate.invoke(ctx).andThen(evaluateFailureCondition(ctx));
+ })
+ .timeout(failoverConfiguration.getSlowCallDuration(), TimeUnit.MILLISECONDS)
+ .retry(failoverConfiguration.getMaxRetries())
+ .compose(CircuitBreakerOperator.of(circuitBreaker(ctx)))
+ .onErrorResumeNext(t -> ctx.interruptWith(new ExecutionFailure(502).cause(t)))
+ .doFinally(() -> recordFailoverMetrics(ctx, totalAttempts.get(), firstFailedEndpoint.get()))
+ );
}
/**
@@ -292,4 +298,51 @@ private CircuitBreaker circuitBreaker(HttpExecutionContext ctx) {
return circuitBreaker;
}
}
+
+ /**
+ * Captures the current request state (path, headers, body) so it can be restored on retry.
+ * Calling {@code body()} also activates internal chunk caching, which is mandatory to replay the request.
+ */
+ private Completable captureRequestState(HttpExecutionContext ctx, AtomicReference snapshotRef) {
+ return ctx
+ .request()
+ .body()
+ // Body present: capture path, headers, and body for full replay
+ .doOnSuccess(body -> snapshotRef.compareAndSet(null, RequestSnapshot.withBody(ctx, body)))
+ // No body (e.g. GET): capture path and headers only
+ .doOnComplete(() -> snapshotRef.compareAndSet(null, RequestSnapshot.headersOnly(ctx)))
+ .ignoreElement();
+ }
+
+ /**
+ * Restores the request to its initial state captured by {@link #captureRequestState}.
+ */
+ private void restoreRequestState(HttpExecutionContext ctx, RequestSnapshot snapshot) {
+ if (ctx.request() instanceof HttpRequestInternal httpRequestInternal) {
+ httpRequestInternal.pathInfo(snapshot.pathInfo());
+ }
+
+ HttpHeaders currentHeaders = ctx.request().headers();
+ currentHeaders.clear();
+ for (var entry : snapshot.headers()) {
+ currentHeaders.add(entry.getKey(), entry.getValue());
+ }
+
+ if (snapshot.body() != null) {
+ ctx.request().body(snapshot.body());
+ }
+ }
+
+ @VisibleForTesting
+ record RequestSnapshot(String pathInfo, HttpHeaders headers, Buffer body) {
+ /** Captures request state including the body content for replay on retry. */
+ static RequestSnapshot withBody(HttpExecutionContext ctx, Buffer body) {
+ return new RequestSnapshot(ctx.request().pathInfo(), HttpHeaders.create(ctx.request().headers()), body);
+ }
+
+ /** Captures request state when no body is present (e.g. GET requests). */
+ static RequestSnapshot headersOnly(HttpExecutionContext ctx) {
+ return new RequestSnapshot(ctx.request().pathInfo(), HttpHeaders.create(ctx.request().headers()), null);
+ }
+ }
}
diff --git a/gravitee-apim-gateway/gravitee-apim-gateway-core/src/test/java/io/gravitee/gateway/reactive/core/failover/FailoverInvokerTest.java b/gravitee-apim-gateway/gravitee-apim-gateway-core/src/test/java/io/gravitee/gateway/reactive/core/failover/FailoverInvokerTest.java
index 33d367f5560..f080df95f15 100644
--- a/gravitee-apim-gateway/gravitee-apim-gateway-core/src/test/java/io/gravitee/gateway/reactive/core/failover/FailoverInvokerTest.java
+++ b/gravitee-apim-gateway/gravitee-apim-gateway-core/src/test/java/io/gravitee/gateway/reactive/core/failover/FailoverInvokerTest.java
@@ -25,6 +25,7 @@
import io.gravitee.definition.model.v4.failover.Failover;
import io.gravitee.el.TemplateEngine;
import io.gravitee.gateway.api.buffer.Buffer;
+import io.gravitee.gateway.api.http.HttpHeaders;
import io.gravitee.gateway.reactive.api.ExecutionFailure;
import io.gravitee.gateway.reactive.api.context.ContextAttributes;
import io.gravitee.gateway.reactive.api.context.InternalContextAttributes;
@@ -84,6 +85,7 @@ void setUp() {
executionContext = new DefaultExecutionContext(request, response);
((DefaultExecutionContext) executionContext).metrics(metrics);
lenient().when(request.body()).thenReturn(Maybe.just(Buffer.buffer("body")));
+ lenient().when(request.headers()).thenReturn(HttpHeaders.create());
}
@Test
diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-automation/gravitee-apim-rest-api-automation-rest/src/main/resources/open-api.yaml b/gravitee-apim-rest-api/gravitee-apim-rest-api-automation/gravitee-apim-rest-api-automation-rest/src/main/resources/open-api.yaml
index c4b11a473cc..a0e38cf6916 100644
--- a/gravitee-apim-rest-api/gravitee-apim-rest-api-automation/gravitee-apim-rest-api-automation-rest/src/main/resources/open-api.yaml
+++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-automation/gravitee-apim-rest-api-automation-rest/src/main/resources/open-api.yaml
@@ -2163,7 +2163,6 @@ components:
default: true
failureCondition:
type: string
- nullable: true
description: An EL expression evaluated on the response to determine if it should be considered a failure (e.g. "{#response.status >= 500}"). If null, response content is not evaluated.
forceNextEndpointOnFailure:
type: boolean
diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/resources/openapi/openapi-apis.yaml b/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/resources/openapi/openapi-apis.yaml
index 917e35d1892..5fd76af2056 100644
--- a/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/resources/openapi/openapi-apis.yaml
+++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/resources/openapi/openapi-apis.yaml
@@ -7599,7 +7599,6 @@ components:
default: true
failureCondition:
type: string
- nullable: true
description: An EL expression evaluated on the response to determine if it should be considered a failure (e.g. "{#response.status >= 500}"). If null, response content is not evaluated.
forceNextEndpointOnFailure:
type: boolean