Skip to content

Commit

Permalink
Implement support for new QoS Metadata
Browse files Browse the repository at this point in the history
For more information, see
palantir/conjure-java-runtime-api#1231
  • Loading branch information
carterkozak committed Oct 8, 2024
1 parent a0299ed commit e0e5377
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 39 deletions.
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));
}
}
}
Loading

0 comments on commit e0e5377

Please sign in to comment.