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

fix(retrofit): use SpinnakerServerException.getHttpMethod() instead of reflection in SpinnakerServerExceptionHandler #4675

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
2 changes: 2 additions & 0 deletions orca-retrofit/orca-retrofit.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-params")
testImplementation("org.mockito:mockito-core")
testImplementation("org.springframework.boot:spring-boot-test")
testImplementation("com.squareup.okhttp3:mockwebserver")
testImplementation("com.squareup.retrofit2:converter-jackson")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.springframework:spring-test")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import static java.net.HttpURLConnection.*

abstract class BaseRetrofitExceptionHandler implements ExceptionHandler {
boolean shouldRetry(Exception e, String kind, Integer responseCode) {
return shouldRetry(e, kind, null, responseCode)
}

boolean shouldRetry(Exception e, String kind, String httpMethod, Integer responseCode) {
dbyron-sf marked this conversation as resolved.
Show resolved Hide resolved
if (isMalformedRequest(kind, e.getMessage())) {
return false
}
Expand All @@ -33,7 +37,11 @@ abstract class BaseRetrofitExceptionHandler implements ExceptionHandler {
return true
}

return isIdempotentRequest(e) && (isNetworkError(kind) || isGatewayErrorCode(kind, responseCode) || isThrottle(kind, responseCode))
if(httpMethod == null) {
httpMethod = findHttpMethodAnnotation(e)
}

return isIdempotentRequest(httpMethod) && (isNetworkError(kind) || isGatewayErrorCode(kind, responseCode) || isThrottle(kind, responseCode))
}

private boolean isGatewayErrorCode(String kind, Integer responseCode) {
Expand All @@ -55,8 +63,8 @@ abstract class BaseRetrofitExceptionHandler implements ExceptionHandler {
return "UNEXPECTED".equals(kind) && exceptionMessage?.contains("Path parameter")
}

private static boolean isIdempotentRequest(Exception e) {
findHttpMethodAnnotation(e) in ["GET", "HEAD", "DELETE", "PUT"]
private static boolean isIdempotentRequest(String httpMethod) {
httpMethod in ["GET", "HEAD", "DELETE", "PUT"]
}

private static String findHttpMethodAnnotation(Exception exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public Response handle(String taskName, Exception exception) {

String kind;
Integer responseCode = null;
String httpMethod = ex.getHttpMethod();

if (ex instanceof SpinnakerNetworkException) {
kind = "NETWORK";
Expand Down Expand Up @@ -108,6 +109,12 @@ public Response handle(String taskName, Exception exception) {

responseDetails.put("kind", kind);

// http method may be null if exception is created from RetrofitError
// so only include in responseDetails when value is valid
if (httpMethod != null) {
responseDetails.put("method", httpMethod);
}

// Although Spinnaker*Exception has a retryable property that other parts of
// spinnaker use, ignore it here for compatibility with
// RetrofitExceptionHandler, specifically because that doesn't retry (most)
Expand All @@ -116,6 +123,6 @@ public Response handle(String taskName, Exception exception) {
ex.getClass().getSimpleName(),
taskName,
responseDetails,
shouldRetry(ex, kind, responseCode));
shouldRetry(ex, kind, httpMethod, responseCode));
dbyron-sf marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
package com.netflix.spinnaker.orca.retrofit.exceptions;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
import com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory;
import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException;
import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException;
import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerRetrofitErrorHandler;
Expand All @@ -33,10 +35,18 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import okhttp3.OkHttpClient;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.SocketPolicy;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpStatus;
import retrofit.RestAdapter;
import retrofit.RetrofitError;
import retrofit.client.Client;
Expand All @@ -45,9 +55,16 @@
import retrofit.converter.GsonConverter;
import retrofit.converter.JacksonConverter;
import retrofit.mime.TypedString;
import retrofit2.Call;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;

class SpinnakerServerExceptionHandlerTest {
private static final String URL = "https://some-url";
private static Retrofit2Service retrofit2Service;

private static final MockWebServer mockWebServer = new MockWebServer();

private static final String URL = mockWebServer.url("https://some-url").toString();

private static final String TASK_NAME = "task name";

Expand Down Expand Up @@ -135,6 +152,35 @@ class SpinnakerServerExceptionHandlerTest {
SpinnakerServerExceptionHandler spinnakerServerExceptionHandler =
new SpinnakerServerExceptionHandler();

@BeforeAll
static void setupOnce() {
retrofit2Service =
new Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.client(
new OkHttpClient.Builder()
.callTimeout(1, TimeUnit.SECONDS)
.connectTimeout(1, TimeUnit.SECONDS)
.build())
.addCallAdapterFactory(ErrorHandlingExecutorCallAdapterFactory.getInstance())
.addConverterFactory(JacksonConverterFactory.create())
.build()
.create(Retrofit2Service.class);
}

@AfterAll
static void shutdownOnce() throws Exception {
mockWebServer.shutdown();
}

interface Retrofit2Service {
@retrofit2.http.GET("/retrofit2")
Call<String> getRetrofit2();

@retrofit2.http.POST("/retrofit2")
Call<String> postRetrofit2();
}

@ParameterizedTest(name = "{index} => onlyHandlesSpinnakerServerExceptionAndSubclasses {0}")
@MethodSource("exceptionsForHandlesTest")
void onlyHandlesSpinnakerServerExceptionAndSubclasses(Exception ex, boolean supported) {
Expand All @@ -153,12 +199,71 @@ private static Stream<Arguments> exceptionsForHandlesTest() {
}

@ParameterizedTest(name = "{index} => testRetryable {0}")
@MethodSource("exceptionsForRetryTest")
@MethodSource({"exceptionsForRetryTest", "retrofit2Exceptions"})
void testRetryable(SpinnakerServerException ex, boolean expectedRetryable) {
ExceptionHandler.Response response = spinnakerServerExceptionHandler.handle(TASK_NAME, ex);
assertThat(response.isShouldRetry()).isEqualTo(expectedRetryable);
}

private static Stream<Arguments> retrofit2Exceptions() {
String responseBody = "{\"test\":\"test\"}";

// HTTP 503 is always retryable
mockWebServer.enqueue(
new MockResponse()
.setResponseCode(HttpStatus.SERVICE_UNAVAILABLE.value())
.setBody(responseBody));
SpinnakerServerException retryable =
catchThrowableOfType(
() -> retrofit2Service.getRetrofit2().execute(), SpinnakerServerException.class);

// not retryable because POST is not idempotent
mockWebServer.enqueue(
new MockResponse().setResponseCode(HttpStatus.BAD_REQUEST.value()).setBody(responseBody));
SpinnakerServerException notRetryable =
catchThrowableOfType(
() -> retrofit2Service.postRetrofit2().execute(), SpinnakerServerException.class);

// not retryable despite idempotent method because response code not within 429, 502, 503, 504
mockWebServer.enqueue(
new MockResponse().setResponseCode(HttpStatus.UNAUTHORIZED.value()).setBody(responseBody));
SpinnakerServerException notRetryableIdempotent =
catchThrowableOfType(
() -> retrofit2Service.getRetrofit2().execute(), SpinnakerServerException.class);

// idempotent network errors are retryable
mockWebServer.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
SpinnakerServerException idempotentNetworkException =
catchThrowableOfType(
() -> retrofit2Service.getRetrofit2().execute(), SpinnakerNetworkException.class);

// throttling is retryable if idempotent
mockWebServer.enqueue(
new MockResponse()
.setResponseCode(HttpStatus.TOO_MANY_REQUESTS.value())
.setBody(responseBody));
SpinnakerServerException throttledIdempotent =
catchThrowableOfType(
() -> retrofit2Service.getRetrofit2().execute(), SpinnakerServerException.class);

// throttling is not retryable if not idempotent
mockWebServer.enqueue(
new MockResponse()
.setResponseCode(HttpStatus.TOO_MANY_REQUESTS.value())
.setBody(responseBody));
SpinnakerServerException throttledNonIdempotent =
catchThrowableOfType(
() -> retrofit2Service.postRetrofit2().execute(), SpinnakerServerException.class);

return Stream.of(
Arguments.of(retryable, true),
Arguments.of(notRetryable, false),
Arguments.of(notRetryableIdempotent, false),
Arguments.of(idempotentNetworkException, true),
Arguments.of(throttledIdempotent, true),
dbyron-sf marked this conversation as resolved.
Show resolved Hide resolved
Arguments.of(throttledNonIdempotent, false));
}

private static Stream<Arguments> exceptionsForRetryTest() throws Exception {
// This isn't retryable because it's not considered an idempotent request
// since there's no retrofit http method annotation in the exception's stack
Expand Down Expand Up @@ -220,7 +325,7 @@ private static Stream<Arguments> exceptionsForRetryTest() throws Exception {
}

@ParameterizedTest(name = "{index} => verifyResponseDetails {0}")
@MethodSource("exceptionsForResponseDetailsTest")
@MethodSource({"exceptionsForResponseDetailsTest", "nonRetryableRetrofit2Exceptions"})
void verifyResponseDetails(SpinnakerHttpException spinnakerHttpException) {
ExceptionHandler.Response response =
spinnakerServerExceptionHandler.handle(TASK_NAME, spinnakerHttpException);
Expand All @@ -229,6 +334,7 @@ void verifyResponseDetails(SpinnakerHttpException spinnakerHttpException) {
// expected. This duplicates some logic in SpinnakerServerExceptionHandler,
// but at least it helps detect future changes.
Map<String, Object> responseBody = spinnakerHttpException.getResponseBody();
String httpMethod = spinnakerHttpException.getHttpMethod();
String error = null;
List<String> errors = null;

Expand All @@ -238,6 +344,10 @@ void verifyResponseDetails(SpinnakerHttpException spinnakerHttpException) {
.put("url", spinnakerHttpException.getUrl())
.put("status", spinnakerHttpException.getResponseCode());

if (httpMethod != null) {
builder.put("method", httpMethod);
}

if (responseBody == null) {
error = spinnakerHttpException.getMessage();
errors = List.of();
Expand Down Expand Up @@ -285,6 +395,12 @@ private static Stream<SpinnakerHttpException> exceptionsForResponseDetailsTest()
.map(SpinnakerServerExceptionHandlerTest::makeSpinnakerHttpException);
}

private static Stream<SpinnakerHttpException> nonRetryableRetrofit2Exceptions() {
return retrofit2Exceptions()
.filter(args -> args.get()[1].equals(false))
.map(args -> ((SpinnakerHttpException) args.get()[0]));
}

private void compareResponse(
ExceptionHandler.Response expectedResponse, ExceptionHandler.Response actualResponse) {
assertThat(actualResponse).isNotNull();
Expand Down