diff --git a/build.gradle b/build.gradle index 3e4807f1e..f8247d40a 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,16 @@ allprojects { version System.env.CIRCLE_TAG ?: gitVersion() group 'com.palantir.conjure.java' + // Remove this override once a final Dialogue release includes retry diagnostics. + configurations.configureEach { + resolutionStrategy.eachDependency { details -> + if (details.requested.group == 'com.palantir.dialogue') { + details.useVersion '6.45.0-rc1' + details.because 'Retry-exhaustion end-to-end tests require the Dialogue feature build' + } + } + } + repositories { mavenCentral() { metadataSources { mavenPom(); ignoreGradleMetadataRedirection() } } } diff --git a/conjure-java-core/build.gradle b/conjure-java-core/build.gradle index 3ff8f8431..837778387 100644 --- a/conjure-java-core/build.gradle +++ b/conjure-java-core/build.gradle @@ -81,6 +81,8 @@ dependencies { testImplementation 'com.palantir.dialogue:dialogue-clients' testImplementation 'com.palantir.safe-logging:preconditions-assertj' testImplementation 'com.palantir.tracing:tracing' + testImplementation 'com.palantir.tritium:tritium-registry' + testImplementation 'io.dropwizard.metrics:metrics-core' testImplementation 'io.dropwizard:dropwizard-core' testImplementation 'io.dropwizard:dropwizard-jersey' testImplementation 'io.dropwizard:dropwizard-jetty' diff --git a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/DialogueEteEndpoints.java b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/DialogueEteEndpoints.java index b2e51097f..fd163b520 100644 --- a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/DialogueEteEndpoints.java +++ b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/DialogueEteEndpoints.java @@ -920,6 +920,71 @@ public String version() { } }, + /** This endpoint hits another service */ + hitOtherService { + private final PathTemplate pathTemplate = + PathTemplate.builder().fixed("base").fixed("otherservice").build(); + + @Override + public void renderPath(ListMultimap params, UrlBuilder url) { + pathTemplate.fill(params, url); + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String endpointName() { + return "hitOtherService"; + } + + @Override + public String version() { + return "1.2.3"; + } + }, + + /** Always returns an internal server error to exercise downstream retries. */ + internalServerError { + private final PathTemplate pathTemplate = PathTemplate.builder() + .fixed("base") + .fixed("errors") + .fixed("internal") + .build(); + + @Override + public void renderPath(ListMultimap params, UrlBuilder url) { + pathTemplate.fill(params, url); + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String endpointName() { + return "internalServerError"; + } + + @Override + public String version() { + return "1.2.3"; + } + }, + aliasLongEndpoint { private final PathTemplate pathTemplate = PathTemplate.builder().fixed("base").fixed("alias-long").build(); diff --git a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceAsync.java b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceAsync.java index 134a1d4de..8c9cef505 100644 --- a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceAsync.java +++ b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceAsync.java @@ -186,6 +186,22 @@ ListenableFuture> optionalEnumQuery( @ClientEndpoint(method = "GET", path = "/base/errors/serialization") ListenableFuture errorParameterSerialization(AuthHeader authHeader, String headerParameter); + /** + * This endpoint hits another service + * + * @apiNote {@code GET /base/otherservice} + */ + @ClientEndpoint(method = "GET", path = "/base/otherservice") + ListenableFuture hitOtherService(AuthHeader authHeader); + + /** + * Always returns an internal server error to exercise downstream retries. + * + * @apiNote {@code GET /base/errors/internal} + */ + @ClientEndpoint(method = "GET", path = "/base/errors/internal") + ListenableFuture internalServerError(AuthHeader authHeader); + /** @apiNote {@code GET /base/alias-long} */ @ClientEndpoint(method = "GET", path = "/base/alias-long") ListenableFuture> aliasLongEndpoint(AuthHeader authHeader, Optional input); @@ -446,6 +462,12 @@ static EteServiceAsync of(EndpointChannelFactory _endpointChannelFactory, Conjur private final EndpointChannel errorParameterSerializationChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.errorParameterSerialization); + private final EndpointChannel hitOtherServiceChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.hitOtherService); + + private final EndpointChannel internalServerErrorChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.internalServerError); + private final EndpointChannel aliasLongEndpointChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.aliasLongEndpoint); @@ -721,6 +743,20 @@ public ListenableFuture errorParameterSerialization(AuthHeader authHeade .call(errorParameterSerializationChannel, _request.build(), stringDeserializer); } + @Override + public ListenableFuture hitOtherService(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + return _runtime.clients().call(hitOtherServiceChannel, _request.build(), stringDeserializer); + } + + @Override + public ListenableFuture internalServerError(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + return _runtime.clients().call(internalServerErrorChannel, _request.build(), stringDeserializer); + } + @Override public ListenableFuture> aliasLongEndpoint( AuthHeader authHeader, Optional input) { diff --git a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceBlocking.java b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceBlocking.java index 528db91db..a25e61427 100644 --- a/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceBlocking.java +++ b/conjure-java-core/src/integrationInput/java/dialogue/com/palantir/product/EteServiceBlocking.java @@ -185,6 +185,22 @@ Optional optionalQueryExter @ClientEndpoint(method = "GET", path = "/base/errors/serialization") String errorParameterSerialization(AuthHeader authHeader, String headerParameter); + /** + * This endpoint hits another service + * + * @apiNote {@code GET /base/otherservice} + */ + @ClientEndpoint(method = "GET", path = "/base/otherservice") + String hitOtherService(AuthHeader authHeader); + + /** + * Always returns an internal server error to exercise downstream retries. + * + * @apiNote {@code GET /base/errors/internal} + */ + @ClientEndpoint(method = "GET", path = "/base/errors/internal") + String internalServerError(AuthHeader authHeader); + /** @apiNote {@code GET /base/alias-long} */ @ClientEndpoint(method = "GET", path = "/base/alias-long") Optional aliasLongEndpoint(AuthHeader authHeader, Optional input); @@ -445,6 +461,12 @@ static EteServiceBlocking of(EndpointChannelFactory _endpointChannelFactory, Con private final EndpointChannel errorParameterSerializationChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.errorParameterSerialization); + private final EndpointChannel hitOtherServiceChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.hitOtherService); + + private final EndpointChannel internalServerErrorChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.internalServerError); + private final EndpointChannel aliasLongEndpointChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.aliasLongEndpoint); @@ -718,6 +740,21 @@ public String errorParameterSerialization(AuthHeader authHeader, String headerPa .callBlocking(errorParameterSerializationChannel, _request.build(), stringDeserializer); } + @Override + public String hitOtherService(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + return _runtime.clients().callBlocking(hitOtherServiceChannel, _request.build(), stringDeserializer); + } + + @Override + public String internalServerError(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + return _runtime.clients() + .callBlocking(internalServerErrorChannel, _request.build(), stringDeserializer); + } + @Override public Optional aliasLongEndpoint(AuthHeader authHeader, Optional input) { Request.Builder _request = Request.builder(); diff --git a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/DialogueEteEndpoints.java b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/DialogueEteEndpoints.java index 50a09a787..3d56d59cf 100644 --- a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/DialogueEteEndpoints.java +++ b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/DialogueEteEndpoints.java @@ -920,6 +920,71 @@ public String version() { } }, + /** This endpoint hits another service */ + hitOtherService { + private final PathTemplate pathTemplate = + PathTemplate.builder().fixed("base").fixed("otherservice").build(); + + @Override + public void renderPath(ListMultimap params, UrlBuilder url) { + pathTemplate.fill(params, url); + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String endpointName() { + return "hitOtherService"; + } + + @Override + public String version() { + return "1.2.3"; + } + }, + + /** Always returns an internal server error to exercise downstream retries. */ + internalServerError { + private final PathTemplate pathTemplate = PathTemplate.builder() + .fixed("base") + .fixed("errors") + .fixed("internal") + .build(); + + @Override + public void renderPath(ListMultimap params, UrlBuilder url) { + pathTemplate.fill(params, url); + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String endpointName() { + return "internalServerError"; + } + + @Override + public String version() { + return "1.2.3"; + } + }, + aliasLongEndpoint { private final PathTemplate pathTemplate = PathTemplate.builder().fixed("base").fixed("alias-long").build(); diff --git a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceAsync.java b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceAsync.java index 1911b2d0b..6b8fe326f 100644 --- a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceAsync.java +++ b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceAsync.java @@ -187,6 +187,22 @@ ListenableFuture> optionalEnumQuery( @ClientEndpoint(method = "GET", path = "/base/errors/serialization") ListenableFuture errorParameterSerialization(AuthHeader authHeader, String headerParameter); + /** + * This endpoint hits another service + * + * @apiNote {@code GET /base/otherservice} + */ + @ClientEndpoint(method = "GET", path = "/base/otherservice") + ListenableFuture hitOtherService(AuthHeader authHeader); + + /** + * Always returns an internal server error to exercise downstream retries. + * + * @apiNote {@code GET /base/errors/internal} + */ + @ClientEndpoint(method = "GET", path = "/base/errors/internal") + ListenableFuture internalServerError(AuthHeader authHeader); + /** @apiNote {@code GET /base/alias-long} */ @ClientEndpoint(method = "GET", path = "/base/alias-long") ListenableFuture> aliasLongEndpoint(AuthHeader authHeader, Optional input); @@ -524,6 +540,12 @@ static EteServiceAsync of(EndpointChannelFactory _endpointChannelFactory, Conjur private final EndpointChannel errorParameterSerializationChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.errorParameterSerialization); + private final EndpointChannel hitOtherServiceChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.hitOtherService); + + private final EndpointChannel internalServerErrorChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.internalServerError); + private final EndpointChannel aliasLongEndpointChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.aliasLongEndpoint); @@ -945,6 +967,30 @@ public ListenableFuture errorParameterSerialization(AuthHeader authHeade .call(errorParameterSerializationChannel, _request.build(), stringDeserializer); } + @Override + public ListenableFuture hitOtherService(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + if (_runtime.bodySerDe().errorParameterFormat().isPresent()) { + _request.putHeaderParams( + "Accept-Conjure-Error-Parameter-Format", + _runtime.bodySerDe().errorParameterFormat().get().toString()); + } + return _runtime.clients().call(hitOtherServiceChannel, _request.build(), stringDeserializer); + } + + @Override + public ListenableFuture internalServerError(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + if (_runtime.bodySerDe().errorParameterFormat().isPresent()) { + _request.putHeaderParams( + "Accept-Conjure-Error-Parameter-Format", + _runtime.bodySerDe().errorParameterFormat().get().toString()); + } + return _runtime.clients().call(internalServerErrorChannel, _request.build(), stringDeserializer); + } + @Override public ListenableFuture> aliasLongEndpoint( AuthHeader authHeader, Optional input) { diff --git a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceBlocking.java b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceBlocking.java index 3d6ce98e0..2666459c7 100644 --- a/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceBlocking.java +++ b/conjure-java-core/src/integrationInput/java/exceptionthrowingdialogueinterfaces/com/palantir/product/EteServiceBlocking.java @@ -186,6 +186,22 @@ Optional optionalQueryExter @ClientEndpoint(method = "GET", path = "/base/errors/serialization") String errorParameterSerialization(AuthHeader authHeader, String headerParameter); + /** + * This endpoint hits another service + * + * @apiNote {@code GET /base/otherservice} + */ + @ClientEndpoint(method = "GET", path = "/base/otherservice") + String hitOtherService(AuthHeader authHeader); + + /** + * Always returns an internal server error to exercise downstream retries. + * + * @apiNote {@code GET /base/errors/internal} + */ + @ClientEndpoint(method = "GET", path = "/base/errors/internal") + String internalServerError(AuthHeader authHeader); + /** @apiNote {@code GET /base/alias-long} */ @ClientEndpoint(method = "GET", path = "/base/alias-long") Optional aliasLongEndpoint(AuthHeader authHeader, Optional input); @@ -523,6 +539,12 @@ static EteServiceBlocking of(EndpointChannelFactory _endpointChannelFactory, Con private final EndpointChannel errorParameterSerializationChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.errorParameterSerialization); + private final EndpointChannel hitOtherServiceChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.hitOtherService); + + private final EndpointChannel internalServerErrorChannel = + _endpointChannelFactory.endpoint(DialogueEteEndpoints.internalServerError); + private final EndpointChannel aliasLongEndpointChannel = _endpointChannelFactory.endpoint(DialogueEteEndpoints.aliasLongEndpoint); @@ -942,6 +964,31 @@ public String errorParameterSerialization(AuthHeader authHeader, String headerPa .callBlocking(errorParameterSerializationChannel, _request.build(), stringDeserializer); } + @Override + public String hitOtherService(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + if (_runtime.bodySerDe().errorParameterFormat().isPresent()) { + _request.putHeaderParams( + "Accept-Conjure-Error-Parameter-Format", + _runtime.bodySerDe().errorParameterFormat().get().toString()); + } + return _runtime.clients().callBlocking(hitOtherServiceChannel, _request.build(), stringDeserializer); + } + + @Override + public String internalServerError(AuthHeader authHeader) { + Request.Builder _request = Request.builder(); + _request.putHeaderParams("Authorization", authHeader.toString()); + if (_runtime.bodySerDe().errorParameterFormat().isPresent()) { + _request.putHeaderParams( + "Accept-Conjure-Error-Parameter-Format", + _runtime.bodySerDe().errorParameterFormat().get().toString()); + } + return _runtime.clients() + .callBlocking(internalServerErrorChannel, _request.build(), stringDeserializer); + } + @Override public Optional aliasLongEndpoint(AuthHeader authHeader, Optional input) { Request.Builder _request = Request.builder(); diff --git a/conjure-java-core/src/integrationInput/java/jersey/com/palantir/product/EteService.java b/conjure-java-core/src/integrationInput/java/jersey/com/palantir/product/EteService.java index 64d299543..bd95d89b5 100644 --- a/conjure-java-core/src/integrationInput/java/jersey/com/palantir/product/EteService.java +++ b/conjure-java-core/src/integrationInput/java/jersey/com/palantir/product/EteService.java @@ -223,6 +223,18 @@ String errorParameterSerialization( @HeaderParam("Authorization") @NotNull AuthHeader authHeader, @HeaderParam("Accept-Conjure-Error-Parameter-Format") String headerParameter); + /** This endpoint hits another service */ + @GET + @Path("base/otherservice") + @ClientEndpoint(method = "GET", path = "/base/otherservice") + String hitOtherService(@HeaderParam("Authorization") @NotNull AuthHeader authHeader); + + /** Always returns an internal server error to exercise downstream retries. */ + @GET + @Path("base/errors/internal") + @ClientEndpoint(method = "GET", path = "/base/errors/internal") + String internalServerError(@HeaderParam("Authorization") @NotNull AuthHeader authHeader); + @GET @Path("base/alias-long") @ClientEndpoint(method = "GET", path = "/base/alias-long") diff --git a/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/EteServiceEndpoints.java b/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/EteServiceEndpoints.java index 9f87faad1..8f42c49a8 100644 --- a/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/EteServiceEndpoints.java +++ b/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/EteServiceEndpoints.java @@ -74,6 +74,8 @@ public List endpoints(UndertowRuntime runtime) { new EnumHeaderEndpoint(runtime, delegate), new JsonErrorsHeaderEndpoint(runtime, delegate), new ErrorParameterSerializationEndpoint(runtime, delegate), + new HitOtherServiceEndpoint(runtime, delegate), + new InternalServerErrorEndpoint(runtime, delegate), new AliasLongEndpointEndpoint(runtime, delegate), new ComplexQueryParametersEndpoint(runtime, delegate), new ReceiveListOfOptionalsEndpoint(runtime, delegate), @@ -1473,6 +1475,98 @@ public HttpHandler handler() { } } + private static final class HitOtherServiceEndpoint implements HttpHandler, Endpoint { + private final UndertowRuntime runtime; + + private final UndertowEteService delegate; + + private final Serializer serializer; + + HitOtherServiceEndpoint(UndertowRuntime runtime, UndertowEteService delegate) { + this.runtime = runtime; + this.delegate = delegate; + this.serializer = runtime.bodySerDe().serializer(TypeMarker.of(String.class), this); + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws IOException { + AuthHeader authHeader = runtime.auth().header(exchange); + String result = delegate.hitOtherService(authHeader); + serializer.serialize(result, exchange); + } + + @Override + public HttpString method() { + return Methods.GET; + } + + @Override + public String template() { + return "/base/otherservice"; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String name() { + return "hitOtherService"; + } + + @Override + public HttpHandler handler() { + return this; + } + } + + private static final class InternalServerErrorEndpoint implements HttpHandler, Endpoint { + private final UndertowRuntime runtime; + + private final UndertowEteService delegate; + + private final Serializer serializer; + + InternalServerErrorEndpoint(UndertowRuntime runtime, UndertowEteService delegate) { + this.runtime = runtime; + this.delegate = delegate; + this.serializer = runtime.bodySerDe().serializer(TypeMarker.of(String.class), this); + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws IOException { + AuthHeader authHeader = runtime.auth().header(exchange); + String result = delegate.internalServerError(authHeader); + serializer.serialize(result, exchange); + } + + @Override + public HttpString method() { + return Methods.GET; + } + + @Override + public String template() { + return "/base/errors/internal"; + } + + @Override + public String serviceName() { + return "EteService"; + } + + @Override + public String name() { + return "internalServerError"; + } + + @Override + public HttpHandler handler() { + return this; + } + } + private static final class AliasLongEndpointEndpoint implements HttpHandler, Endpoint { private final UndertowRuntime runtime; diff --git a/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/UndertowEteService.java b/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/UndertowEteService.java index 26699055e..9df37dd3c 100644 --- a/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/UndertowEteService.java +++ b/conjure-java-core/src/integrationInput/java/undertow/com/palantir/product/UndertowEteService.java @@ -133,6 +133,20 @@ Optional optionalQueryExter */ String errorParameterSerialization(AuthHeader authHeader, String headerParameter); + /** + * This endpoint hits another service + * + * @apiNote {@code GET /base/otherservice} + */ + String hitOtherService(AuthHeader authHeader); + + /** + * Always returns an internal server error to exercise downstream retries. + * + * @apiNote {@code GET /base/errors/internal} + */ + String internalServerError(AuthHeader authHeader); + /** @apiNote {@code GET /base/alias-long} */ Optional aliasLongEndpoint(AuthHeader authHeader, Optional input); diff --git a/conjure-java-core/src/test/java/com/palantir/conjure/java/JerseyEteResource.java b/conjure-java-core/src/test/java/com/palantir/conjure/java/JerseyEteResource.java index 8a02de90a..1e89129d3 100644 --- a/conjure-java-core/src/test/java/com/palantir/conjure/java/JerseyEteResource.java +++ b/conjure-java-core/src/test/java/com/palantir/conjure/java/JerseyEteResource.java @@ -16,6 +16,8 @@ package com.palantir.conjure.java; +import com.palantir.conjure.java.api.errors.ErrorType; +import com.palantir.conjure.java.api.errors.ServiceException; import com.palantir.conjure.java.lib.SafeLong; import com.palantir.conjure.java.undertow.lib.BinaryResponseBody; import com.palantir.ri.ResourceIdentifier; @@ -186,6 +188,16 @@ public String errorParameterSerialization(AuthHeader authHeader, String headerPa throw new UnsupportedOperationException("Conjure-Java does not support Jersey any longer."); } + @Override + public String hitOtherService(AuthHeader authHeader) { + return ""; + } + + @Override + public String internalServerError(AuthHeader _authHeader) { + throw new ServiceException(ErrorType.INTERNAL); + } + @Override public Optional aliasLongEndpoint(AuthHeader _authHeader, Optional input) { return input; diff --git a/conjure-java-core/src/test/java/com/palantir/conjure/java/RetriesExhaustedEteTest.java b/conjure-java-core/src/test/java/com/palantir/conjure/java/RetriesExhaustedEteTest.java new file mode 100644 index 000000000..f880c4ba1 --- /dev/null +++ b/conjure-java-core/src/test/java/com/palantir/conjure/java/RetriesExhaustedEteTest.java @@ -0,0 +1,542 @@ +/* + * (c) Copyright 2026 Palantir Technologies Inc. All rights reserved. + * + * Licensed 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 com.palantir.conjure.java; + +import static com.palantir.conjure.java.EteTestServer.clientConfiguration; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +import com.codahale.metrics.Meter; +import com.google.common.base.Throwables; +import com.google.common.collect.Iterables; +import com.palantir.conjure.java.api.errors.ErrorType; +import com.palantir.conjure.java.api.errors.QosException; +import com.palantir.conjure.java.api.errors.RemoteException; +import com.palantir.conjure.java.api.errors.ServiceException; +import com.palantir.conjure.java.api.errors.UnknownRemoteException; +import com.palantir.conjure.java.client.config.ClientConfiguration; +import com.palantir.conjure.java.undertow.runtime.ConjureHandler; +import com.palantir.dialogue.clients.DialogueClients; +import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.SafeLoggable; +import com.palantir.tritium.metrics.registry.DefaultTaggedMetricRegistry; +import com.palantir.tritium.metrics.registry.TaggedMetricRegistry; +import dialogue.com.palantir.product.EmptyPathServiceBlocking; +import io.undertow.Handlers; +import io.undertow.Undertow; +import io.undertow.server.HttpHandler; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntPredicate; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import undertow.com.palantir.product.EmptyPathServiceEndpoints; + +/** Each arrow in A -> B -> C -> D uses a Dialogue client and a separate Undertow server. */ +@Execution(ExecutionMode.SAME_THREAD) +@Timeout(30) +final class RetriesExhaustedEteTest { + private static final HttpString RETRIES_EXHAUSTED_HEADER = new HttpString("Dialogue-Retries-Exhausted"); + private static final String DIAGNOSTIC_METRIC_PREFIX = "dialogue.client.request.retry.diagnostic."; + private static final ObservedResponse SUCCESS = new ObservedResponse(200, Optional.empty()); + private static final ObservedResponse INTERNAL_ERROR = new ObservedResponse(500, Optional.empty()); + private static final ObservedResponse EXHAUSTED_INTERNAL_ERROR = new ObservedResponse(500, Optional.of("true")); + private static final ObservedResponse BAD_REQUEST = new ObservedResponse(400, Optional.empty()); + private static final ObservedResponse EXHAUSTED_CONFLICT = new ObservedResponse(409, Optional.of("true")); + private static final ObservedResponse UNAVAILABLE = new ObservedResponse(503, Optional.empty()); + private static final ObservedResponse EXHAUSTED_UNAVAILABLE = new ObservedResponse(503, Optional.of("true")); + + private final List servers = new ArrayList<>(); + + @AfterEach + void stopServers() { + for (int index = servers.size() - 1; index >= 0; index--) { + servers.get(index).stop(); + } + } + + @ParameterizedTest(name = "C succeeds on request {0} within B's retry budget") + @ValueSource(ints = {1, 3}) + void succeedsWithinDownstreamRetryBudget(int successAt) { + TestServer serverC = server("C", attempt -> succeedOnOrAfter(attempt, successAt)); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(2); + + assertThat(aToB.call()).isTrue(); + + serverB.assertResponses(SUCCESS); + serverC.assertResponses(successAt - 1, INTERNAL_ERROR, SUCCESS); + aToB.assertDiagnostics(); + bToC.assertDiagnostics(); + } + + @ParameterizedTest(name = "C succeeds on request {0} after {1} diagnostic retries by A") + @CsvSource({"4, 1", "12, 3"}) + void succeedsAfterDownstreamExhaustion(int successAt, int diagnosticRetries) { + TestServer serverC = server("C", attempt -> succeedOnOrAfter(attempt, successAt)); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(3); + + assertThat(aToB.call()).isTrue(); + + serverB.assertResponses(diagnosticRetries, EXHAUSTED_INTERNAL_ERROR, SUCCESS); + serverC.assertResponses(successAt - 1, INTERNAL_ERROR, SUCCESS); + aToB.assertDiagnostics(new DiagnosticMetrics("success", diagnosticRetries, 1)); + bToC.assertDiagnostics(); + } + + @Test + void stopsBeforeRecoveryWhenRetryBudgetIsTooSmall() { + TestServer serverC = server("C", attempt -> succeedOnOrAfter(attempt, 12)); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(2); + + aToB.assertFailure(500, true); + + // Three attempts by A, each allowing three attempts by B, cannot reach C's twelfth request. + serverB.assertResponses(3, EXHAUSTED_INTERNAL_ERROR); + serverC.assertResponses(9, INTERNAL_ERROR); + aToB.assertDiagnostics(new DiagnosticMetrics("failure", 2, 1)); + bToC.assertDiagnostics(); + } + + @Test + void propagatesExhaustionThroughIntermediateServiceWithRetriesDisabled() { + TestServer serverD = server("D", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient cToD = serverD.client(2); + TestServer serverC = server("C", _attempt -> cToD.call()); + TestClient bToC = serverC.client(0); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(0); + + // Neither A nor B can add an exhaustion marker by retrying: both must receive it from downstream. + aToB.assertFailure(500, true); + + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR); + serverC.assertResponses(EXHAUSTED_INTERNAL_ERROR); + serverD.assertResponses(3, INTERNAL_ERROR); + aToB.assertDiagnostics(); + bToC.assertDiagnostics(); + cToD.assertDiagnostics(); + } + + @Test + void exhaustsRetriesAtEveryHopInThreeHopChain() { + TestServer serverD = server("D", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient cToD = serverD.client(1); + TestServer serverC = server("C", _attempt -> cToD.call()); + TestClient bToC = serverC.client(1); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(1); + + aToB.assertFailure(500, true); + + serverB.assertResponses(2, EXHAUSTED_INTERNAL_ERROR); + serverC.assertResponses(4, EXHAUSTED_INTERNAL_ERROR); + serverD.assertResponses(8, INTERNAL_ERROR); + aToB.assertDiagnostics(new DiagnosticMetrics("failure", 1, 1)); + bToC.assertDiagnostics(new DiagnosticMetrics("failure", 2, 2)); + cToD.assertDiagnostics(); + } + + @Test + void succeedsOnTwelfthRequestInThreeHopChain() { + TestServer serverD = server("D", attempt -> succeedOnOrAfter(attempt, 12)); + TestClient cToD = serverD.client(2); + TestServer serverC = server("C", _attempt -> cToD.call()); + TestClient bToC = serverC.client(1); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(1); + + assertThat(aToB.call()).isTrue(); + + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR, SUCCESS); + serverC.assertResponses(3, EXHAUSTED_INTERNAL_ERROR, SUCCESS); + serverD.assertResponses(11, INTERNAL_ERROR, SUCCESS); + aToB.assertDiagnostics(new DiagnosticMetrics("success", 1, 1)); + // B's first call to C fails, while its second call recovers after one diagnostic retry. + bToC.assertDiagnostics(new DiagnosticMetrics("failure", 1, 1), new DiagnosticMetrics("success", 1, 1)); + cToD.assertDiagnostics(); + } + + @Test + void propagatesExhaustionWhenBWrapsTheFailureInANonRetryableError() { + TestServer serverC = server("C", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", _attempt -> { + try { + return bToC.call(); + } catch (RemoteException exception) { + throw new ServiceException(ErrorType.CONFLICT, exception); + } + }); + TestClient aToB = serverB.client(2); + + aToB.assertFailure(409, true); + + serverB.assertResponses(EXHAUSTED_CONFLICT); + serverC.assertResponses(3, INTERNAL_ERROR); + // Receiving the marker alone does not count as a diagnostic retry, and a 409 must not be retried. + aToB.assertDiagnostics(); + bToC.assertDiagnostics(); + } + + @Test + void stopsOnAnUnmarkedNonRetryableErrorAfterADiagnosticRetry() { + TestServer serverC = server("C", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", attempt -> { + if (attempt == 1) { + return bToC.call(); + } + throw new ServiceException(ErrorType.INVALID_ARGUMENT); + }); + TestClient aToB = serverB.client(3); + + aToB.assertFailure(400, false); + + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR, BAD_REQUEST); + serverC.assertResponses(3, INTERNAL_ERROR); + aToB.assertDiagnostics(new DiagnosticMetrics("failure", 1, 1)); + bToC.assertDiagnostics(); + } + + @Test + void continuesCountingDiagnosticRetriesAfterAnUnmarkedRetryableError() { + TestServer serverC = server("C", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", attempt -> { + if (attempt == 1) { + return bToC.call(); + } + return succeedOnOrAfter(attempt, 3); + }); + TestClient aToB = serverB.client(3); + + assertThat(aToB.call()).isTrue(); + + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR, INTERNAL_ERROR, SUCCESS); + serverC.assertResponses(3, INTERNAL_ERROR); + aToB.assertDiagnostics(new DiagnosticMetrics("success", 2, 1)); + bToC.assertDiagnostics(); + } + + @Test + void diagnosticStateDoesNotLeakToTheNextCallOnTheSameClient() { + TestServer serverC = server("C", attempt -> succeedOnOrAfter(attempt, 4)); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", attempt -> { + if (attempt <= 2) { + return bToC.call(); + } + return succeedOnOrAfter(attempt, 5); + }); + TestClient aToB = serverB.client(2); + + assertThat(aToB.call()).isTrue(); + aToB.assertDiagnostics(new DiagnosticMetrics("success", 1, 1)); + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR, SUCCESS); + + // This call retries twice, but neither failure comes from an exhausted downstream client. + assertThat(aToB.call()).isTrue(); + + serverB.assertResponses(EXHAUSTED_INTERNAL_ERROR, SUCCESS, INTERNAL_ERROR, INTERNAL_ERROR, SUCCESS); + serverC.assertResponses(3, INTERNAL_ERROR, SUCCESS); + aToB.assertDiagnostics(new DiagnosticMetrics("success", 1, 1)); + bToC.assertDiagnostics(); + } + + @ParameterizedTest(name = "A retries a local B failure {0} times") + @ValueSource(ints = {0, 2}) + void localFailuresDoNotProduceDiagnosticRetries(int retries) { + TestServer serverB = server("B", _attempt -> { + throw new ServiceException(ErrorType.INTERNAL); + }); + TestClient aToB = serverB.client(retries); + + // When retries are enabled, A adds its own marker after exhausting them. B never sent a marker. + aToB.assertFailure(500, retries > 0); + + serverB.assertResponses(retries + 1, INTERNAL_ERROR); + aToB.assertDiagnostics(); + } + + @Test + void propagatesExhaustionForQosUnavailable() { + TestServer serverC = server("C", _attempt -> { + throw QosException.unavailable(); + }); + TestClient bToC = serverC.client(2); + TestServer serverB = server("B", _attempt -> bToC.call()); + TestClient aToB = serverB.client(1); + + assertThatThrownBy(aToB::call) + .isInstanceOfSatisfying( + QosException.Unavailable.class, failure -> assertExhaustionMarker(failure, true)); + + serverB.assertResponses(2, EXHAUSTED_UNAVAILABLE); + serverC.assertResponses(6, UNAVAILABLE); + aToB.assertDiagnostics(new DiagnosticMetrics("failure", 1, 1)); + bToC.assertDiagnostics(); + } + + @ParameterizedTest(name = "plain-text 500: B retries = {0}, A retries = {1}") + @CsvSource({"0, 0", "2, 0", "2, 1"}) + void propagatesExhaustionFromUnknownRemoteException(int downstreamRetries, int outerRetries) { + AtomicInteger downstreamAttempts = new AtomicInteger(); + TestClient bToC = rawServerClient( + exchange -> { + downstreamAttempts.incrementAndGet(); + exchange.setStatusCode(500); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); + exchange.getResponseSender().send("Downstream failed"); + }, + downstreamRetries); + List downstreamFailures = new CopyOnWriteArrayList<>(); + TestServer serverB = server("B", _attempt -> { + try { + return bToC.call(); + } catch (UnknownRemoteException failure) { + downstreamFailures.add(failure); + throw failure; + } + }); + TestClient aToB = serverB.client(outerRetries); + boolean retriesExhausted = downstreamRetries > 0; + + aToB.assertFailure(500, retriesExhausted); + + // Assert what B receives before Conjure converts it to a normal INTERNAL error for A. + assertThat(downstreamFailures).hasSize(outerRetries + 1).allSatisfy(failure -> { + assertThat(failure.getStatus()).isEqualTo(500); + assertThat(failure.getBody()).isEqualTo("Downstream failed"); + assertExhaustionMarker(failure, retriesExhausted); + }); + assertThat(downstreamAttempts).hasValue((downstreamRetries + 1) * (outerRetries + 1)); + serverB.assertResponses(outerRetries + 1, retriesExhausted ? EXHAUSTED_INTERNAL_ERROR : INTERNAL_ERROR); + if (outerRetries == 0) { + aToB.assertDiagnostics(); + } else { + aToB.assertDiagnostics(new DiagnosticMetrics("failure", outerRetries, 1)); + } + bToC.assertDiagnostics(); + } + + @ParameterizedTest(name = "connection closed: B retries = {0}, A retries = {1}") + @CsvSource({"0, 0", "2, 0", "2, 1"}) + void propagatesExhaustionFromIoException(int downstreamRetries, int outerRetries) { + AtomicInteger downstreamAttempts = new AtomicInteger(); + TestClient bToC = rawServerClient( + exchange -> { + downstreamAttempts.incrementAndGet(); + // Fail at the transport layer, before any HTTP response or exhaustion header can be sent. + exchange.getConnection().close(); + }, + downstreamRetries); + List downstreamFailures = new CopyOnWriteArrayList<>(); + TestServer serverB = server("B", _attempt -> { + try { + return bToC.call(); + } catch (RuntimeException failure) { + downstreamFailures.add(failure); + throw failure; + } + }); + TestClient aToB = serverB.client(outerRetries); + boolean retriesExhausted = downstreamRetries > 0; + + aToB.assertFailure(500, retriesExhausted); + + assertThat(downstreamFailures).hasSize(outerRetries + 1).allSatisfy(failure -> { + // Blocking Dialogue clients wrap checked IOExceptions; the marker lives on the cause. + assertThat(failure).hasCauseInstanceOf(IOException.class); + assertExhaustionMarker(failure.getCause(), retriesExhausted); + }); + assertThat(downstreamAttempts).hasValue((downstreamRetries + 1) * (outerRetries + 1)); + serverB.assertResponses(outerRetries + 1, retriesExhausted ? EXHAUSTED_INTERNAL_ERROR : INTERNAL_ERROR); + if (outerRetries == 0) { + aToB.assertDiagnostics(); + } else { + aToB.assertDiagnostics(new DiagnosticMetrics("failure", outerRetries, 1)); + } + bToC.assertDiagnostics(); + } + + private TestServer server(String name, IntPredicate response) { + TestServer server = new TestServer(name, response); + servers.add(server.server); + return server; + } + + private TestClient rawServerClient(HttpHandler handler, int retries) { + Undertow server = startServer(handler); + servers.add(server); + return new TestClient(port(server), retries); + } + + private static Undertow startServer(HttpHandler handler) { + Undertow server = Undertow.builder() + .setIoThreads(1) + .setWorkerThreads(4) + .addHttpListener(0, "localhost") + .setHandler(Handlers.path().addPrefixPath("/test-example/api", handler)) + .build(); + server.start(); + return server; + } + + private static int port(Undertow server) { + return ((InetSocketAddress) + Iterables.getOnlyElement(server.getListenerInfo()).getAddress()) + .getPort(); + } + + private static boolean succeedOnOrAfter(int attempt, int successAt) { + if (attempt < successAt) { + throw new ServiceException(ErrorType.INTERNAL); + } + return true; + } + + private static void assertExhaustionMarker(Throwable failure, boolean expected) { + // Blocking clients may wrap the remote exception to capture the calling thread's stack trace. + boolean hasMarker = Throwables.getCausalChain(failure).stream() + .flatMap(cause -> Arrays.stream(cause.getSuppressed())) + .filter(SafeLoggable.class::isInstance) + .map(SafeLoggable.class::cast) + .flatMap(diagnostic -> diagnostic.getArgs().stream()) + .anyMatch(SafeArg.of(RETRIES_EXHAUSTED_HEADER.toString(), "true")::equals); + assertThat(hasMarker).as("Exhaustion marker on the client exception").isEqualTo(expected); + } + + private record ObservedResponse(int status, Optional retriesExhaustedHeader) {} + + private record DiagnosticMetrics(String result, long retries, long requests) {} + + private static final class TestServer { + private final String name; + private final AtomicInteger attempts = new AtomicInteger(); + private final List responses = new CopyOnWriteArrayList<>(); + private final Undertow server; + private final int port; + + TestServer(String name, IntPredicate response) { + this.name = name; + HttpHandler handler = ConjureHandler.builder() + .services(EmptyPathServiceEndpoints.of(() -> response.test(attempts.incrementAndGet()))) + .build(); + HttpHandler recordingHandler = exchange -> { + // Capture the actual outgoing status/header before the client can receive the response. + exchange.addResponseCommitListener(committed -> responses.add(new ObservedResponse( + committed.getStatusCode(), + Optional.ofNullable(committed.getResponseHeaders().getFirst(RETRIES_EXHAUSTED_HEADER))))); + handler.handleRequest(exchange); + }; + server = startServer(recordingHandler); + port = port(server); + } + + TestClient client(int retries) { + return new TestClient(port, retries); + } + + void assertResponses(ObservedResponse... expected) { + assertThat(attempts.get()).as("Requests reaching %s", name).isEqualTo(expected.length); + assertThat(responses).as("Responses sent by %s", name).containsExactly(expected); + } + + void assertResponses(int repetitions, ObservedResponse repeated, ObservedResponse... remaining) { + List expected = new ArrayList<>(Collections.nCopies(repetitions, repeated)); + expected.addAll(Arrays.asList(remaining)); + assertResponses(expected.toArray(ObservedResponse[]::new)); + } + } + + private static final class TestClient { + private final TaggedMetricRegistry registry = new DefaultTaggedMetricRegistry(); + private final EmptyPathServiceBlocking delegate; + + TestClient(int port, int retries) { + delegate = DialogueClients.create( + EmptyPathServiceBlocking.class, + ClientConfiguration.builder() + .from(clientConfiguration(port)) + .maxNumRetries(retries) + .backoffSlotSize(Duration.ZERO) + .taggedMetricRegistry(registry) + .build()); + } + + boolean call() { + return delegate.emptyPath(); + } + + void assertFailure(int status, boolean retriesExhausted) { + assertThatThrownBy(this::call).isInstanceOfSatisfying(RemoteException.class, failure -> { + assertThat(failure.getStatus()).isEqualTo(status); + assertExhaustionMarker(failure, retriesExhausted); + }); + } + + void assertDiagnostics(DiagnosticMetrics... expected) { + List actualMetrics = registry.getMetrics().entrySet().stream() + .filter(entry -> entry.getKey().safeName().startsWith(DIAGNOSTIC_METRIC_PREFIX)) + .map(entry -> tuple( + entry.getKey().safeName(), + entry.getKey().safeTags().get("result"), + ((Meter) entry.getValue()).getCount())) + .toList(); + List expectedMetrics = new ArrayList<>(); + for (DiagnosticMetrics metrics : expected) { + expectedMetrics.add(tuple(DIAGNOSTIC_METRIC_PREFIX + "retries", metrics.result(), metrics.retries())); + expectedMetrics.add(tuple(DIAGNOSTIC_METRIC_PREFIX + "requests", metrics.result(), metrics.requests())); + } + assertThat(actualMetrics).containsExactlyInAnyOrderElementsOf(expectedMetrics); + } + } +} diff --git a/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowEteResource.java b/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowEteResource.java index 0b45d7c70..418662f41 100644 --- a/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowEteResource.java +++ b/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowEteResource.java @@ -16,12 +16,15 @@ package com.palantir.conjure.java; +import com.palantir.conjure.java.api.errors.ErrorType; +import com.palantir.conjure.java.api.errors.ServiceException; import com.palantir.conjure.java.lib.Bytes; import com.palantir.conjure.java.lib.SafeLong; import com.palantir.conjure.java.undertow.lib.BinaryResponseBody; import com.palantir.ri.ResourceIdentifier; import com.palantir.tokens.auth.AuthHeader; import com.palantir.tokens.auth.BearerToken; +import dialogue.com.palantir.product.EteServiceBlocking; import errors.com.palantir.product.AnyExample; import errors.com.palantir.product.CollectionAlias; import errors.com.palantir.product.CollectionExample; @@ -51,6 +54,8 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import undertow.com.palantir.product.LongAlias; import undertow.com.palantir.product.NestedStringAliasExample; import undertow.com.palantir.product.SimpleEnum; @@ -59,6 +64,13 @@ import undertow.com.palantir.product.UndertowEteService; public final class UndertowEteResource implements UndertowEteService { + private final Supplier downstreamClient; + private final AtomicInteger internalServerErrorAttempts = new AtomicInteger(); + + public UndertowEteResource(Supplier downstreamClient) { + this.downstreamClient = downstreamClient; + } + @Override public String string(AuthHeader _authHeader) { return "Hello, world!"; @@ -301,6 +313,21 @@ public String errorParameterSerialization(AuthHeader authHeader, String headerPa return "hello!"; } + @Override + public String hitOtherService(AuthHeader authHeader) { + return downstreamClient.get().internalServerError(authHeader); + } + + @Override + public String internalServerError(AuthHeader _authHeader) { + internalServerErrorAttempts.incrementAndGet(); + throw new ServiceException(ErrorType.INTERNAL); + } + + int getInternalServerErrorAttempts() { + return internalServerErrorAttempts.get(); + } + @Override public Optional aliasLongEndpoint(AuthHeader _authHeader, Optional input) { return input; diff --git a/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowServiceEteTest.java b/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowServiceEteTest.java index a6649ed46..0ca2b2846 100644 --- a/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowServiceEteTest.java +++ b/conjure-java-core/src/test/java/com/palantir/conjure/java/UndertowServiceEteTest.java @@ -22,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Suppliers; import com.google.common.collect.Iterables; import com.google.common.net.HttpHeaders; import com.google.common.util.concurrent.Futures; @@ -29,6 +30,7 @@ import com.palantir.conjure.java.api.errors.RemoteException; import com.palantir.conjure.java.api.errors.SerializableError; import com.palantir.conjure.java.api.errors.SerializableErrorProvider; +import com.palantir.conjure.java.client.config.ClientConfiguration; import com.palantir.conjure.java.client.jaxrs.JaxRsClient; import com.palantir.conjure.java.lib.SafeLong; import com.palantir.conjure.java.okhttp.HostMetricsRegistry; @@ -61,6 +63,7 @@ import java.net.InetSocketAddress; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -91,6 +94,7 @@ public final class UndertowServiceEteTest extends TestBase { public static File folder; private static Undertow server; + private static UndertowEteResource eteResource; private final EteServiceBlocking client; private final EteServiceAsync asyncClient; @@ -110,9 +114,16 @@ public UndertowServiceEteTest() { @BeforeAll public static void before() { + eteResource = new UndertowEteResource(Suppliers.memoize(() -> DialogueClients.create( + EteServiceBlocking.class, + ClientConfiguration.builder() + .from(clientConfiguration(port)) + .maxNumRetries(2) + .backoffSlotSize(Duration.ZERO) + .build()))); HttpHandler handler = ConjureHandler.builder() - .services(EteServiceEndpoints.of(new UndertowEteResource())) + .services(EteServiceEndpoints.of(eteResource)) .services(EmptyPathServiceEndpoints.of(() -> true)) .services(EteBinaryServiceEndpoints.of(new UndertowBinaryResource())) .build(); diff --git a/conjure-java-core/src/test/resources/ete-service.yml b/conjure-java-core/src/test/resources/ete-service.yml index 0e3b4ff81..571ba88bb 100644 --- a/conjure-java-core/src/test/resources/ete-service.yml +++ b/conjure-java-core/src/test/resources/ete-service.yml @@ -235,6 +235,18 @@ services: type: string returns: string + hitOtherService: + http: GET /otherservice + docs: | + This endpoint hits another service + returns: string + + internalServerError: + http: GET /errors/internal + docs: | + Always returns an internal server error to exercise downstream retries. + returns: string + aliasLongEndpoint: http: GET /alias-long args: @@ -277,4 +289,4 @@ services: http: PUT /union args: value: SimpleUnion - returns: SimpleUnion \ No newline at end of file + returns: SimpleUnion diff --git a/conjure-java-undertow-runtime/build.gradle b/conjure-java-undertow-runtime/build.gradle index bf885d380..78927c1e2 100644 --- a/conjure-java-undertow-runtime/build.gradle +++ b/conjure-java-undertow-runtime/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation 'io.undertow:undertow-core' implementation 'org.checkerframework:checker-qual' implementation 'org.jboss.xnio:xnio-api' + implementation 'org.jspecify:jspecify' implementation 'org.slf4j:slf4j-api' implementation 'com.fasterxml.jackson.core:jackson-annotations' diff --git a/conjure-java-undertow-runtime/src/main/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptions.java b/conjure-java-undertow-runtime/src/main/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptions.java index 4198648de..98322ef70 100644 --- a/conjure-java-undertow-runtime/src/main/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptions.java +++ b/conjure-java-undertow-runtime/src/main/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptions.java @@ -28,12 +28,15 @@ import com.palantir.conjure.java.api.errors.QosReasons.QosResponseEncodingAdapter; import com.palantir.conjure.java.api.errors.RemoteException; import com.palantir.conjure.java.api.errors.ServiceException; +import com.palantir.conjure.java.api.errors.UnknownRemoteException; import com.palantir.conjure.java.undertow.lib.ExceptionHandler; import com.palantir.conjure.java.undertow.lib.Serializer; import com.palantir.conjure.java.undertow.lib.TypeMarker; import com.palantir.deadlines.DeadlineExpiredException; import com.palantir.deadlines.DeadlineExpiredReasons; +import com.palantir.logsafe.Arg; import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.SafeLoggable; import com.palantir.logsafe.logger.SafeLogger; import com.palantir.logsafe.logger.SafeLoggerFactory; import io.undertow.io.UndertowOutputStream; @@ -47,6 +50,7 @@ import java.util.Collections; import java.util.Optional; import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; import org.xnio.IoUtils; /** @@ -57,6 +61,7 @@ public enum ConjureExceptions implements ExceptionHandler { INSTANCE; private static final SafeLogger log = SafeLoggerFactory.get(ConjureExceptions.class); + private static final int MAX_CAUSE_DEPTH = 100; // Exceptions should always be serialized using JSON private static final Serializer serializer = new ConjureBodySerDe(Collections.singletonList(Encodings.json())).serializer(new TypeMarker<>() {}); @@ -64,9 +69,16 @@ public enum ConjureExceptions implements ExceptionHandler { // Log at most once every second private static final RateLimiter qosLoggingRateLimiter = RateLimiter.create(1); + // Exhausted retries logic + private static final String RETRIES_EXHAUSTED = "Dialogue-Retries-Exhausted"; + private static final HttpString RETRIES_EXHAUSTED_HEADER = new HttpString(RETRIES_EXHAUSTED); + @SuppressWarnings("CyclomaticComplexity") @Override public void handle(HttpServerExchange exchange, Throwable throwable) { + if (isRetriesExhausted(throwable)) { + exchange.getResponseHeaders().put(RETRIES_EXHAUSTED_HEADER, "true"); + } setFailure(exchange, throwable); if (throwable instanceof EndpointServiceException endpointServiceException) { endpointServiceException(exchange, endpointServiceException); @@ -102,6 +114,33 @@ public void handle(HttpServerExchange exchange, Throwable throwable) { } } + private static boolean isRetriesExhausted(Throwable failure) { + @Nullable Throwable current = failure; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof QosException + || current instanceof RemoteException + || current instanceof UnknownRemoteException + || current instanceof IOException) { + for (Throwable suppressed : current.getSuppressed()) { + if (suppressed instanceof SafeLoggable loggable && hasRetriesExhaustedArg(loggable)) { + return true; + } + } + } + current = current.getCause(); + } + return false; + } + + private static boolean hasRetriesExhaustedArg(SafeLoggable loggable) { + for (Arg arg : loggable.getArgs()) { + if (RETRIES_EXHAUSTED.equals(arg.getName()) && "true".equalsIgnoreCase(String.valueOf(arg.getValue()))) { + return true; + } + } + return false; + } + private static void checkedServiceException(HttpServerExchange exchange, CheckedServiceException exception) { log(exception); writeResponse( diff --git a/conjure-java-undertow-runtime/src/test/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptionHandlerTest.java b/conjure-java-undertow-runtime/src/test/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptionHandlerTest.java index 8cac4d233..9e85df638 100644 --- a/conjure-java-undertow-runtime/src/test/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptionHandlerTest.java +++ b/conjure-java-undertow-runtime/src/test/java/com/palantir/conjure/java/undertow/runtime/ConjureExceptionHandlerTest.java @@ -34,11 +34,13 @@ import com.palantir.conjure.java.api.errors.RemoteException; import com.palantir.conjure.java.api.errors.SerializableError; import com.palantir.conjure.java.api.errors.ServiceException; +import com.palantir.conjure.java.api.errors.UnknownRemoteException; import com.palantir.conjure.java.serialization.ObjectMappers; import com.palantir.conjure.java.undertow.HttpServerExchanges; import com.palantir.conjure.java.undertow.lib.TypeMarker; import com.palantir.deadlines.DeadlineExpiredException; import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.exceptions.SafeRuntimeException; import io.undertow.Undertow; import io.undertow.server.HttpHandler; import io.undertow.server.handlers.BlockingHandler; @@ -64,6 +66,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; public final class ConjureExceptionHandlerTest { @@ -346,13 +349,59 @@ public void handlesQosExceptionRetryOther() throws IOException { assertThat(connection.getErrorStream()).isNull(); } - @Test - public void handlesQosExceptionUnavailable() throws IOException { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void handlesQosExceptionUnavailable(boolean retriesExhausted) throws IOException { exception = QosException.unavailable(); + if (retriesExhausted) { + exception.addSuppressed( + new SafeRuntimeException("Retries exhausted", SafeArg.of("Dialogue-Retries-Exhausted", "true"))); + } HttpURLConnection connection = execute(); assertThat(connection.getResponseCode()).isEqualTo(503); assertThat(connection.getErrorStream()).isNull(); + assertThat(connection.getHeaderField("Dialogue-Retries-Exhausted")).isEqualTo(retriesExhausted ? "true" : null); + } + + @ParameterizedTest(name = "{0}, wrapped = {2}, exhausted = {3}") + @MethodSource("retryMarkerExceptions") + void propagatesRetriesExhaustedMarkerForSupportedExceptions( + Exception downstreamFailure, int unwrappedStatus, boolean wrapped, boolean retriesExhausted) + throws IOException { + if (retriesExhausted) { + downstreamFailure.addSuppressed( + new SafeRuntimeException("Retries exhausted", SafeArg.of("Dialogue-Retries-Exhausted", "true"))); + } + exception = wrapped + ? new ServiceException(ErrorType.CONFLICT, new RuntimeException(downstreamFailure)) + : downstreamFailure; + + HttpURLConnection connection = execute(); + + assertThat(connection.getResponseCode()).isEqualTo(wrapped ? 409 : unwrappedStatus); + assertThat(connection.getHeaderField("Dialogue-Retries-Exhausted")).isEqualTo(retriesExhausted ? "true" : null); + } + + private static Stream retryMarkerExceptions() { + return Stream.of(false, true) + .flatMap(wrapped -> Stream.of(false, true) + .flatMap(retriesExhausted -> Stream.of( + Arguments.of(QosException.unavailable(), 503, wrapped, retriesExhausted), + Arguments.of( + new RemoteException( + SerializableError.forException( + new ServiceException(ErrorType.INTERNAL)), + 500), + 500, + wrapped, + retriesExhausted), + Arguments.of( + new UnknownRemoteException(500, "Downstream failed"), + 500, + wrapped, + retriesExhausted), + Arguments.of(new IOException("Socket failed"), 500, wrapped, retriesExhausted)))); } @Test diff --git a/versions.lock b/versions.lock index 59e58cbbf..758b12d5b 100644 --- a/versions.lock +++ b/versions.lock @@ -32,7 +32,7 @@ com.google.errorprone:error_prone_annotations:2.50.0 (31 constraints: fa0329d6) com.google.guava:failureaccess:1.0.3 (1 constraints: 160ae3b4) -com.google.guava:guava:33.7.2-jre (37 constraints: aabd927b) +com.google.guava:guava:33.7.2-jre (37 constraints: a7bd5778) com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava (1 constraints: bd17c918) @@ -44,35 +44,35 @@ com.palantir.conjure:conjure-api-objects:4.57.0 (3 constraints: a827c123) com.palantir.conjure:conjure-generator-common:4.57.0 (2 constraints: 0b14fe83) -com.palantir.conjure.java.api:errors:2.81.0 (10 constraints: 75b9bc69) +com.palantir.conjure.java.api:errors:2.81.0 (10 constraints: 71b99a68) -com.palantir.conjure.java.api:service-config:2.81.0 (6 constraints: 3866fcd8) +com.palantir.conjure.java.api:service-config:2.81.0 (6 constraints: 356669d8) -com.palantir.conjure.java.api:ssl-config:2.81.0 (5 constraints: b54fd72c) +com.palantir.conjure.java.api:ssl-config:2.81.0 (5 constraints: b44fd42c) -com.palantir.conjure.java.runtime:client-config:8.45.0 (6 constraints: c06bf632) +com.palantir.conjure.java.runtime:client-config:8.45.0 (6 constraints: b76b3d31) com.palantir.conjure.java.runtime:conjure-java-jackson-optimizations:8.45.0 (1 constraints: 871ccfa4) -com.palantir.conjure.java.runtime:conjure-java-jackson-serialization:8.45.0 (4 constraints: 384a3823) +com.palantir.conjure.java.runtime:conjure-java-jackson-serialization:8.45.0 (4 constraints: 354a2f23) -com.palantir.conjure.java.runtime:keystores:8.45.0 (3 constraints: 202ab45f) +com.palantir.conjure.java.runtime:keystores:8.45.0 (3 constraints: 1d2aab5f) com.palantir.deadlines:deadlines:0.28.0 (2 constraints: a714d2ac) -com.palantir.dialogue:dialogue-apache-hc5-client:6.47.0 (2 constraints: 80290002) +com.palantir.dialogue:dialogue-apache-hc5-client:6.45.0-rc1 (2 constraints: b12af3aa) -com.palantir.dialogue:dialogue-blocking-channels:6.47.0 (2 constraints: e12341b4) +com.palantir.dialogue:dialogue-blocking-channels:6.45.0-rc1 (2 constraints: 432660d5) -com.palantir.dialogue:dialogue-clients:6.47.0 (1 constraints: 4305683b) +com.palantir.dialogue:dialogue-clients:6.45.0-rc1 (1 constraints: 44056b3b) -com.palantir.dialogue:dialogue-core:6.47.0 (3 constraints: 843d64e2) +com.palantir.dialogue:dialogue-core:6.45.0-rc1 (3 constraints: e63f90cf) -com.palantir.dialogue:dialogue-futures:6.47.0 (3 constraints: da335ac0) +com.palantir.dialogue:dialogue-futures:6.45.0-rc1 (3 constraints: 6d37752c) -com.palantir.dialogue:dialogue-serde:6.47.0 (3 constraints: c22eb0a4) +com.palantir.dialogue:dialogue-serde:6.45.0-rc1 (3 constraints: f42f3663) -com.palantir.dialogue:dialogue-target:6.47.0 (7 constraints: 0b7956b9) +com.palantir.dialogue:dialogue-target:6.45.0-rc1 (7 constraints: 017ff812) com.palantir.goethe:goethe:0.30.0 (1 constraints: 35052b3b) @@ -84,23 +84,23 @@ com.palantir.nylon:nylon-threads:0.16.0 (1 constraints: 3f103ea2) com.palantir.refreshable:refreshable:2.18.0 (3 constraints: 3e34ecf9) -com.palantir.ri:resource-identifier:2.25.0 (3 constraints: 57257a4d) +com.palantir.ri:resource-identifier:2.25.0 (3 constraints: 5325104d) -com.palantir.safe-logging:logger:3.20.0 (24 constraints: 65a9180e) +com.palantir.safe-logging:logger:3.20.0 (24 constraints: 7da93515) com.palantir.safe-logging:logger-slf4j:3.20.0 (1 constraints: 300e8b50) com.palantir.safe-logging:logger-spi:3.20.0 (2 constraints: 6f1e0cb0) -com.palantir.safe-logging:preconditions:3.20.0 (32 constraints: 3c2cb8c1) +com.palantir.safe-logging:preconditions:3.20.0 (32 constraints: 542c3dcd) -com.palantir.safe-logging:safe-logging:3.20.0 (29 constraints: 0bffc271) +com.palantir.safe-logging:safe-logging:3.20.0 (29 constraints: 23ffa77a) com.palantir.safethreadlocalrandom:safe-thread-local-random:0.14.0 (1 constraints: 670f1586) com.palantir.syntactic-paths:syntactic-paths:0.23.0 (2 constraints: f613c781) -com.palantir.tokens:auth-tokens:4.12.0 (5 constraints: 5854659c) +com.palantir.tokens:auth-tokens:4.12.0 (5 constraints: 5454fb9b) com.palantir.tracing:tracing:6.41.0 (8 constraints: 697edd0a) @@ -108,17 +108,17 @@ com.palantir.tracing:tracing-api:6.41.0 (6 constraints: 8c583322) com.palantir.tracing:tracing-undertow:6.41.0 (1 constraints: 3d05563b) -com.palantir.tritium:tritium-api:0.139.0 (2 constraints: 9b1f24f5) +com.palantir.tritium:tritium-api:0.138.0 (2 constraints: 991feff4) -com.palantir.tritium:tritium-caffeine:0.139.0 (1 constraints: e8101fcf) +com.palantir.tritium:tritium-caffeine:0.138.0 (1 constraints: e7101ccf) -com.palantir.tritium:tritium-core:0.139.0 (1 constraints: 7510beb2) +com.palantir.tritium:tritium-core:0.138.0 (1 constraints: 7410bbb2) com.palantir.tritium:tritium-ids:0.128.0 (1 constraints: fe0f9aa6) -com.palantir.tritium:tritium-metrics:0.139.0 (6 constraints: 9f682734) +com.palantir.tritium:tritium-metrics:0.138.0 (6 constraints: 9a683132) -com.palantir.tritium:tritium-registry:0.139.0 (11 constraints: 84df6ada) +com.palantir.tritium:tritium-registry:0.139.0 (11 constraints: 7edf86d7) com.squareup:javapoet:1.13.0 (1 constraints: f50b65f7) diff --git a/versions.props b/versions.props index c00bf83f0..afbcd7130 100644 --- a/versions.props +++ b/versions.props @@ -12,7 +12,7 @@ com.palantir.conjure.java.runtime:* = 8.45.0 com.palantir.conjure.verification:* = 0.19.0 com.palantir.conjure:* = 4.57.0 com.palantir.deadlines:* = 0.28.0 -com.palantir.dialogue:* = 6.47.0 +com.palantir.dialogue:* = 6.48.0 com.palantir.goethe:* = 0.30.0 com.palantir.human-readable-types:* = 1.19.0 com.palantir.javapoet:javapoet = 0.20.0