Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement support for new QoS Metadata #2984

Merged
merged 2 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/@unreleased/pr-2984.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: improvement
improvement:
description: Implement support for new QoS Metadata
links:
- https://github.com/palantir/conjure-java-runtime/pull/2984
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Collection<String>> headers = ImmutableMap.of();
QosReason expected = QosReason.builder().reason("client-qos-response").build();
Map<String, Collection<String>> 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<String, Collection<String>> 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<String, Collection<String>> 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<String, Collection<String>> 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<String, Collection<String>> 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<String, Collection<String>> 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<String, Collection<String>> headersFor(QosReason reason) {
Multimap<String, String> headers = ArrayListMultimap.create();
QosReasons.encodeToResponse(reason, headers, Multimap::put);
return Multimaps.asMap(headers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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<Response.ResponseBuilder> {
INSTANCE;

@Override
public void setHeader(ResponseBuilder builder, String headerName, String headerValue) {
builder.header(headerName, headerValue);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
Expand All @@ -55,11 +72,42 @@ 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();
Response response = mapper.toResponse(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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand All @@ -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<ResponseBuilder> {
INSTANCE;

@Override
public void setHeader(ResponseBuilder builder, String headerName, String headerValue) {
builder.header(headerName, headerValue);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<QosException> mapResponseCodeHeaderStream(
Expand All @@ -50,7 +50,7 @@ public static Optional<QosException> mapResponseCode(int code, Function<String,
case 429:
return Optional.of(map429(headerFn));
case 503:
return Optional.of(map503());
return Optional.of(map503(headerFn));
}

return Optional.empty();
Expand All @@ -65,7 +65,7 @@ private static Optional<QosException> map308(Function<String, String> 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",
Expand All @@ -78,12 +78,25 @@ private static Optional<QosException> map308(Function<String, String> headerFn)
private static QosException map429(Function<String, String> 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<String, String> headerFn) {
return QosException.unavailable(parseQosReason(headerFn));
}

private static QosException map503() {
return QosException.unavailable(QOS_REASON);
private static QosReason parseQosReason(Function<String, String> headerFn) {
return QosReasons.parseFromResponse(headerFn, QosAdapter.INSTANCE);
}

private enum QosAdapter implements QosResponseDecodingAdapter<Function<String, String>> {
INSTANCE;

@Override
public Optional<String> getFirstHeader(Function<String, String> stringStringFunction, String headerName) {
return Optional.ofNullable(stringStringFunction.apply(headerName));
}
}
}