diff --git a/changelog/@unreleased/pr-2984.v2.yml b/changelog/@unreleased/pr-2984.v2.yml new file mode 100644 index 000000000..95184c6e4 --- /dev/null +++ b/changelog/@unreleased/pr-2984.v2.yml @@ -0,0 +1,5 @@ +type: improvement +improvement: + description: Implement support for new QoS Metadata + links: + - https://github.com/palantir/conjure-java-runtime/pull/2984 diff --git a/conjure-java-jaxrs-client/src/test/java/com/palantir/conjure/java/client/jaxrs/feignimpl/QosErrorDecoderTest.java b/conjure-java-jaxrs-client/src/test/java/com/palantir/conjure/java/client/jaxrs/feignimpl/QosErrorDecoderTest.java index 99be7c819..d2575210d 100644 --- a/conjure-java-jaxrs-client/src/test/java/com/palantir/conjure/java/client/jaxrs/feignimpl/QosErrorDecoderTest.java +++ b/conjure-java-jaxrs-client/src/test/java/com/palantir/conjure/java/client/jaxrs/feignimpl/QosErrorDecoderTest.java @@ -18,51 +18,98 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; import com.palantir.conjure.java.api.errors.QosException; +import com.palantir.conjure.java.api.errors.QosReason; +import com.palantir.conjure.java.api.errors.QosReason.DueTo; +import com.palantir.conjure.java.api.errors.QosReason.RetryHint; +import com.palantir.conjure.java.api.errors.QosReasons; import feign.Response; import feign.codec.ErrorDecoder; import jakarta.ws.rs.core.HttpHeaders; import java.time.Duration; import java.util.Collection; import java.util.Map; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public final class QosErrorDecoderTest { private static final String methodKey = "method"; - private QosErrorDecoder decoder; - - @BeforeEach - public void before() { - decoder = new QosErrorDecoder(new ErrorDecoder.Default()); + static QosErrorDecoder decoder() { + return new QosErrorDecoder(new ErrorDecoder.Default()); } @Test public void http_429_throw_qos_throttle() { - Map> headers = ImmutableMap.of(); + QosReason expected = QosReason.builder().reason("client-qos-response").build(); + Map> headers = headersFor(expected); Response response = Response.create(429, "too many requests", headers, new byte[0]); - assertThat(decoder.decode(methodKey, response)) - .isInstanceOfSatisfying(QosException.Throttle.class, e -> assertThat(e.getRetryAfter()) - .isEmpty()); + assertThat(decoder().decode(methodKey, response)) + .isInstanceOfSatisfying(QosException.Throttle.class, throttle -> { + assertThat(throttle.getRetryAfter()).isEmpty(); + assertThat(throttle.getReason()).isEqualTo(expected); + }); + } + + @Test + public void http_429_throw_qos_throttle_with_metadata() { + QosReason expected = QosReason.builder() + .reason("client-qos-response") + .dueTo(DueTo.CUSTOM) + .retryHint(RetryHint.DO_NOT_RETRY) + .build(); + Map> headers = headersFor(expected); + Response response = Response.create(429, "too many requests", headers, new byte[0]); + assertThat(decoder().decode(methodKey, response)) + .isInstanceOfSatisfying(QosException.Throttle.class, throttle -> { + assertThat(throttle.getRetryAfter()).isEmpty(); + assertThat(throttle.getReason()).isEqualTo(expected); + }); } @Test public void http_429_throw_qos_throttle_with_retry_after() { Map> headers = ImmutableMap.of(HttpHeaders.RETRY_AFTER, ImmutableList.of("5")); Response response = Response.create(429, "too many requests", headers, new byte[0]); - assertThat(decoder.decode(methodKey, response)) + assertThat(decoder().decode(methodKey, response)) .isInstanceOfSatisfying(QosException.Throttle.class, e -> assertThat(e.getRetryAfter()) .contains(Duration.ofSeconds(5))); } @Test public void http_503_throw_qos_unavailable() { - Map> headers = ImmutableMap.of(); - Response response = Response.create(503, "too many requests", headers, new byte[0]); - assertThat(decoder.decode(methodKey, response)).isInstanceOf(QosException.Unavailable.class); + QosReason expected = QosReason.builder().reason("client-qos-response").build(); + Map> headers = headersFor(expected); + Response response = Response.create(503, "unavailable", headers, new byte[0]); + assertThat(decoder().decode(methodKey, response)) + .isInstanceOfSatisfying( + QosException.Unavailable.class, + unavailable -> assertThat(unavailable.getReason()).isEqualTo(expected)); + } + + @Test + public void http_503_throw_qos_unavailable_with_metadata() { + QosReason expected = QosReason.builder() + .reason("client-qos-response") + .dueTo(DueTo.CUSTOM) + .retryHint(RetryHint.DO_NOT_RETRY) + .build(); + Map> headers = headersFor(expected); + Response response = Response.create(503, "unavailable", headers, new byte[0]); + assertThat(decoder().decode(methodKey, response)) + .isInstanceOfSatisfying( + QosException.Unavailable.class, + unavailable -> assertThat(unavailable.getReason()).isEqualTo(expected)); + } + + private static Map> headersFor(QosReason reason) { + Multimap headers = ArrayListMultimap.create(); + QosReasons.encodeToResponse(reason, headers, Multimap::put); + return Multimaps.asMap(headers); } } diff --git a/conjure-java-jersey-jakarta-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java b/conjure-java-jersey-jakarta-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java index ee9f05fd8..111d7fe28 100644 --- a/conjure-java-jersey-jakarta-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java +++ b/conjure-java-jersey-jakarta-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java @@ -18,9 +18,12 @@ import com.google.common.net.HttpHeaders; import com.palantir.conjure.java.api.errors.QosException; +import com.palantir.conjure.java.api.errors.QosReasons; +import com.palantir.conjure.java.api.errors.QosReasons.QosResponseEncodingAdapter; import com.palantir.logsafe.logger.SafeLogger; import com.palantir.logsafe.logger.SafeLoggerFactory; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; import java.time.temporal.ChronoUnit; @@ -52,6 +55,7 @@ public Response toResponseInner(QosException qosException) { @Override public Response visit(QosException.Throttle exception) { Response.ResponseBuilder response = Response.status(429); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); exception .getRetryAfter() .ifPresent(duration -> response.header( @@ -61,15 +65,27 @@ public Response visit(QosException.Throttle exception) { @Override public Response visit(QosException.RetryOther exception) { - return Response.status(308) - .header(HttpHeaders.LOCATION, exception.getRedirectTo().toString()) - .build(); + Response.ResponseBuilder response = Response.status(308) + .header(HttpHeaders.LOCATION, exception.getRedirectTo().toString()); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); + return response.build(); } @Override - public Response visit(QosException.Unavailable _exception) { - return Response.status(503).build(); + public Response visit(QosException.Unavailable exception) { + Response.ResponseBuilder response = Response.status(503); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); + return response.build(); } }); } + + private enum ResponseBuilderQosAdapter implements QosResponseEncodingAdapter { + INSTANCE; + + @Override + public void setHeader(ResponseBuilder builder, String headerName, String headerValue) { + builder.header(headerName, headerValue); + } + } } diff --git a/conjure-java-jersey-jakarta-server/src/test/java/com/palantir/conjure/java/server/jersey/QosExceptionMapperTest.java b/conjure-java-jersey-jakarta-server/src/test/java/com/palantir/conjure/java/server/jersey/QosExceptionMapperTest.java index ce2fc261f..9ed81634e 100644 --- a/conjure-java-jersey-jakarta-server/src/test/java/com/palantir/conjure/java/server/jersey/QosExceptionMapperTest.java +++ b/conjure-java-jersey-jakarta-server/src/test/java/com/palantir/conjure/java/server/jersey/QosExceptionMapperTest.java @@ -20,6 +20,9 @@ import com.google.common.collect.ImmutableList; import com.palantir.conjure.java.api.errors.QosException; +import com.palantir.conjure.java.api.errors.QosReason; +import com.palantir.conjure.java.api.errors.QosReason.DueTo; +import com.palantir.conjure.java.api.errors.QosReason.RetryHint; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import java.net.URL; @@ -47,6 +50,20 @@ public void testThrottle_withDuration() throws Exception { assertThat(response.getHeaders()).containsEntry("Retry-After", ImmutableList.of("120")); } + @Test + public void testThrottle_withMetadata() { + QosException exception = QosException.throttle(QosReason.builder() + .reason("reason") + .retryHint(RetryHint.DO_NOT_RETRY) + .dueTo(DueTo.CUSTOM) + .build()); + Response response = mapper.toResponse(exception); + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getHeaders()) + .containsEntry("Qos-Retry-Hint", ImmutableList.of("do-not-retry")) + .containsEntry("Qos-Due-To", ImmutableList.of("custom")); + } + @Test public void testRetryOther() throws Exception { QosException exception = QosException.retryOther(new URL("http://foo")); @@ -55,6 +72,23 @@ public void testRetryOther() throws Exception { assertThat(response.getHeaders()).containsEntry("Location", ImmutableList.of("http://foo")); } + @Test + public void testRetryOther_withMetadata() throws Exception { + QosException exception = QosException.retryOther( + QosReason.builder() + .reason("reason") + .retryHint(RetryHint.DO_NOT_RETRY) + .dueTo(DueTo.CUSTOM) + .build(), + new URL("http://foo")); + Response response = mapper.toResponse(exception); + assertThat(response.getStatus()).isEqualTo(308); + assertThat(response.getHeaders()) + .containsEntry("Qos-Retry-Hint", ImmutableList.of("do-not-retry")) + .containsEntry("Qos-Due-To", ImmutableList.of("custom")) + .containsEntry("Location", ImmutableList.of("http://foo")); + } + @Test public void testUnavailable() throws Exception { QosException exception = QosException.unavailable(); @@ -62,4 +96,18 @@ public void testUnavailable() throws Exception { assertThat(response.getStatus()).isEqualTo(503); assertThat(response.getHeaders()).isEmpty(); } + + @Test + public void testUnavailable_withMetadata() { + QosException exception = QosException.unavailable(QosReason.builder() + .reason("reason") + .retryHint(RetryHint.DO_NOT_RETRY) + .dueTo(DueTo.CUSTOM) + .build()); + Response response = mapper.toResponse(exception); + assertThat(response.getStatus()).isEqualTo(503); + assertThat(response.getHeaders()) + .containsEntry("Qos-Retry-Hint", ImmutableList.of("do-not-retry")) + .containsEntry("Qos-Due-To", ImmutableList.of("custom")); + } } diff --git a/conjure-java-jersey-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java b/conjure-java-jersey-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java index 7d0b3a416..168dc035e 100644 --- a/conjure-java-jersey-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java +++ b/conjure-java-jersey-server/src/main/java/com/palantir/conjure/java/server/jersey/QosExceptionMapper.java @@ -18,10 +18,13 @@ import com.google.common.net.HttpHeaders; import com.palantir.conjure.java.api.errors.QosException; +import com.palantir.conjure.java.api.errors.QosReasons; +import com.palantir.conjure.java.api.errors.QosReasons.QosResponseEncodingAdapter; import com.palantir.logsafe.logger.SafeLogger; import com.palantir.logsafe.logger.SafeLoggerFactory; import java.time.temporal.ChronoUnit; import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.ResponseBuilder; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -52,6 +55,7 @@ public Response toResponseInner(QosException qosException) { @Override public Response visit(QosException.Throttle exception) { Response.ResponseBuilder response = Response.status(429); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); exception .getRetryAfter() .ifPresent(duration -> response.header( @@ -61,15 +65,27 @@ public Response visit(QosException.Throttle exception) { @Override public Response visit(QosException.RetryOther exception) { - return Response.status(308) - .header(HttpHeaders.LOCATION, exception.getRedirectTo().toString()) - .build(); + Response.ResponseBuilder response = Response.status(308) + .header(HttpHeaders.LOCATION, exception.getRedirectTo().toString()); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); + return response.build(); } @Override - public Response visit(QosException.Unavailable _exception) { - return Response.status(503).build(); + public Response visit(QosException.Unavailable exception) { + Response.ResponseBuilder response = Response.status(503); + QosReasons.encodeToResponse(exception.getReason(), response, ResponseBuilderQosAdapter.INSTANCE); + return response.build(); } }); } + + private enum ResponseBuilderQosAdapter implements QosResponseEncodingAdapter { + INSTANCE; + + @Override + public void setHeader(ResponseBuilder builder, String headerName, String headerValue) { + builder.header(headerName, headerValue); + } + } } diff --git a/conjure-java-legacy-clients/src/main/java/com/palantir/conjure/java/QosExceptionResponseMapper.java b/conjure-java-legacy-clients/src/main/java/com/palantir/conjure/java/QosExceptionResponseMapper.java index 8dcb6c09a..79d683ce2 100644 --- a/conjure-java-legacy-clients/src/main/java/com/palantir/conjure/java/QosExceptionResponseMapper.java +++ b/conjure-java-legacy-clients/src/main/java/com/palantir/conjure/java/QosExceptionResponseMapper.java @@ -19,6 +19,8 @@ import com.google.common.net.HttpHeaders; import com.palantir.conjure.java.api.errors.QosException; import com.palantir.conjure.java.api.errors.QosReason; +import com.palantir.conjure.java.api.errors.QosReasons; +import com.palantir.conjure.java.api.errors.QosReasons.QosResponseDecodingAdapter; import com.palantir.logsafe.UnsafeArg; import com.palantir.logsafe.logger.SafeLogger; import com.palantir.logsafe.logger.SafeLoggerFactory; @@ -33,8 +35,6 @@ public final class QosExceptionResponseMapper { private static final SafeLogger log = SafeLoggerFactory.get(QosExceptionResponseMapper.class); - private static final QosReason QOS_REASON = QosReason.of("client-qos-response"); - private QosExceptionResponseMapper() {} public static Optional mapResponseCodeHeaderStream( @@ -50,7 +50,7 @@ public static Optional mapResponseCode(int code, Function map308(Function headerFn) } try { - return Optional.of(QosException.retryOther(QOS_REASON, new URL(locationHeader))); + return Optional.of(QosException.retryOther(parseQosReason(headerFn), new URL(locationHeader))); } catch (MalformedURLException e) { log.error( "Failed to parse location header, not performing redirect", @@ -78,12 +78,25 @@ private static Optional map308(Function headerFn) private static QosException map429(Function headerFn) { String duration = headerFn.apply(HttpHeaders.RETRY_AFTER); if (duration != null) { - return QosException.throttle(QOS_REASON, Duration.ofSeconds(Long.parseLong(duration))); + return QosException.throttle(parseQosReason(headerFn), Duration.ofSeconds(Long.parseLong(duration))); } - return QosException.throttle(QOS_REASON); + return QosException.throttle(parseQosReason(headerFn)); + } + + private static QosException map503(Function headerFn) { + return QosException.unavailable(parseQosReason(headerFn)); } - private static QosException map503() { - return QosException.unavailable(QOS_REASON); + private static QosReason parseQosReason(Function headerFn) { + return QosReasons.parseFromResponse(headerFn, QosAdapter.INSTANCE); + } + + private enum QosAdapter implements QosResponseDecodingAdapter> { + INSTANCE; + + @Override + public Optional getFirstHeader(Function stringStringFunction, String headerName) { + return Optional.ofNullable(stringStringFunction.apply(headerName)); + } } }