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