Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Add ErrorHandler for customization of RetrofitError behavior #174

Merged
merged 1 commit into from

3 participants

@sberan

Add a new interface, ErrorHandler, which is responsible for throwing customized exceptions during synchronous request errors, or directing to the appropriate callback method for asynchronous request errors.

Currently, API users must catch RetrofitError and try to determine the cause of the error based on certain properties of the HTTP response. This interface allows API clients to expose semantic exceptions to their users.

For example, if I had an API interface method to create a User, I might expose an InvalidUserException which could be declared on the createUser method, and used to indicate that something was invalid about the request.

This implementation allows exceptions to be checked or unchecked, leaving that decision up to the API designer. It also doesn't change any existing behavior. Let me know if there's anything I can do to clean it up!

@sberan

Is there any way I can help this move along? Documentation? Different approach entirely?

@JakeWharton
Owner

I am working on changing the architecture of how requests get executed altogether. I don't want to merge this when the API most likely will be forced to change.

@sberan

Congrats on the 1.0! I'm going to get this updated to the latest code line shortly.

@sberan

@JakeWharton This is updated and tested to work against the latest codebase.

@adriancole

I like the idea here. Only thing I'd mention is that sometimes a fallback is required. For example, if you have boolean userExists(String user) then you could configure via this or another mechanism the ability to coerce 404 to false. Do you think it should be in the ErrorHandler, or something else like @Fallback(NotFoundToFalse.class)

@sberan

@adriancole An interesting idea! I think one could make a case that this behavior belongs in ErrorHandler, since the user is handling an HTTP error, and making a decision to swallow that error.

Another thing this would enable is completely ignoring an error - with the current implementation it is not possible to ignore an error. With a small tweak, these use cases would be possible.

@adriancole
@sberan

@adriancole I slightly modified the ExceptionHandler interface to allow an implementation to return a default value instead of throwing an exception.

@adriancole

comments in!

@sberan

@adriancole I slightly disprefer the propogateOrFallback method name because it breaks the congruity with the asynchronous method's name. I updated the comments to more clearly define the terms of the contract.

@adriancole

sounds like a plan. @JakeWharton you have any opinions on this? I'd love to use it this weekend.

@adriancole

last "prefer" is to squash the commits into one.

retrofit/src/main/java/retrofit/ErrorHandler.java
((14 lines not shown))
+ * thrown on the interface method.
+ *
+ * @param cause the original RetrofitError exception
+ * @return a fallback object to be returned from the client interface method
+ * @throws Throwable an exception which will be thrown from the client interface method
+ */
+ Object handleError(RetrofitError cause) throws Throwable;
+
+ /**
+ * Called when errors occur during asynchronous requests. This method is responsible
+ * for calling the appropriate failure method in the callback class.
+ *
+ * @param e the original RetrofitError exception
+ * @param callback the callback which was passed to the interface method
+ */
+ void handleErrorCallback(RetrofitError e, Callback<?> callback);
@JakeWharton Owner

I don't think we need this. You could provide an abstract class which implements Callback for behavior customization. We do something like this:

public abstract class RestCallback<T> implements Callback<T> {
  @Override public final void failure(RetrofitError e) {
    Response r = e.getResponse();
    if (r != null) {
      int status = r.getStatus();
      if (status >= 500) {
        serverError(r);
      } else if (status == 401) {
        sessionExpired(r);
      } else if (status >= 400) {
        clientError(r);
      } else {
        throw new AssertionError(e);
      }
    } else if (e.isNetworkError()) {
      networkError(e.getCause());
    } else {
      unexpectedError(e.getCause());
    }
  }

  public abstract void sessionExpired(Response r);
  public abstract void clientError(Response r);
  public abstract void serverError(Response r);
  public abstract void networkError(Throwable e);
  public abstract void unexpectedError(Throwable e);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
retrofit/src/test/java/retrofit/ErrorHandlerTest.java
((11 lines not shown))
+import retrofit.http.Part;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.fest.assertions.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.verify;
+
+public class ErrorHandlerTest {
+
+
+ interface ExampleClient {
+ @POST("/") @Multipart
+ Void create(@Part("object") Object toCreate) throws InvalidObjectException;
@JakeWharton Owner

Lowercase 'v' void

I think we should support lower "void" conceding perhaps not as a part of this PR

@JakeWharton Owner

Returning Void is weird too. Can you just switch it to Response for clarity that it's synchronous?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@JakeWharton
Owner

I think I'm onboard with the concept of customizing the exception thrown during synchronous invocation. As I mention above, I don't think the asynchronous one is necessary, though. Can we remove that and then I'll take a deeper look at the implementation?

@adriancole

I'm also cool with sync-only. Solves my problems ( :) ) and allows us to move this change forward.

@sberan

I removed the async handling - you're right that it's not necessary. I also changed the method to return a String and added a test for the fallback case as well.

I'm wondering if I should pass along some more information to the ErrorHandler as well - in order to support @adriancole 's Empty*On404 cases, the return type of the method would need to be known.

@JakeWharton
Owner

You have checkstyle errors:

src/main/java/retrofit/ErrorHandler.java:16: Line has trailing spaces.
retrofit/src/main/java/retrofit/ErrorHandler.java
((8 lines not shown))
+public interface ErrorHandler {
+ /**
+ * Called when errors occur during synchronous requests. The implementer
+ * may choose to propagate a custom exception, or return a fallback value.
+ *
+ * If the exception is checked, any thrown exceptions must be declared to be
+ * thrown on the interface method.
+ *
+ * @param cause the original RetrofitError exception
+ * @return a fallback object to be returned from the client interface method
+ * @throws Throwable an exception which will be thrown from the client interface method
+ */
+ Object handleError(RetrofitError cause) throws Throwable;
+
+ ErrorHandler DEFAULT = new ErrorHandler() {
+ @Override
@JakeWharton Owner

This annotation should be on the same line as the method declaration below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@adriancole

Agreed. We will need a TypeToken available, either explicitly as a parameter, or in scope for the implementation (ex ThreadLocal)

@sberan

@adriancole @JakeWharton cleaned up checkstyle errors, and added the interface method as a param to the ErrorHandler hook.

I also renamed the interface method to propagateOrFallback at @adriancole's recommendation.

@adriancole

@sberan I'm going to try and make a realistic test case that uses the Method param.. I think we'll only need TypeToken or Class

@adriancole

OK I've started playing around with the commit. I'm taking the following client into consideration

  interface ExampleClient {
    @GET("/")
    String throwsCustomException() throws IllegalStateException;

    @HEAD("/")
    boolean exists();
  }

I'd revise the naming and args slightly for ErrorHandler

Object fallbackOrPropagate(RetrofitError cause, Type type); // ex. convert response.status 404 -> false

With this together with Converter, I can write a fairly sensibly coupled system to account for these concerns:

    public class MyConverter implements ErrorHandler extends GsonConverter {
      @Override
      Object fromBody(TypedInput body, Type type) throws ConversionException {
        if (type == boolean.class)
          return true;
        else if (type == String.class)
          return new String(Utils.streamToBytes(body.in()));
        return super.fromResponse(response, type);
      }

      @Override
      public Object fallbackOrPropagate(RetrofitError cause, Type type) throws Throwable {
        if (cause.getResponse().getStatus() == 404 && type == boolean.class)
          return false;
        else if (cause.getResponse().getStatus() == 409)
          throw new IllegalStateException(cause.getMessage());
        throw cause;
      }
    }

Thoughts?

@adriancole

note (to those reading email) I revised my comment on github

@adriancole

revised my comments as there's nothing in this issue that requires action in issue #224

@sberan

My idea with passing the entire method was that additional information such as annotations could be useful.

@adriancole
@adriancole

@sberan I'll push a branch showing what I mean shortly.

@adriancole

here's what I was thinking.

adriancole@master...exceptionHandler

This ensures reflection stuff only happens once (restMethodInfo), and allows the fallback to be specified on a method.

ex.

  static interface FallbackClient {
    @GET("/")
    boolean globalFallback();

    @GET("/")
    @Fallback(FalseOn404.class)
    boolean methodFallback();
  }

  static class FalseOn404 implements FallbackHandler  {
    public Object fallbackOrPropagate(Type type, RetrofitError error) throws Throwable {
      if (error.getResponse().getStatus() == 404 && type == boolean.class)
        return false;
      throw error;
    }
  }

feel free to cherry-pick and squash the commit into yours, if helpful.

retrofit/src/main/java/retrofit/ErrorHandler.java
((8 lines not shown))
+ * @author Sam Beran sberan@gmail.com
+ */
+public interface ErrorHandler {
+ /**
+ * Called when errors occur during synchronous requests. The implementer
+ * may choose to propagate a custom exception, or return a fallback value.
+ *
+ * If the exception is checked, any thrown exceptions must be declared to be
+ * thrown on the interface method.
+ *
+ * @param cause the original RetrofitError exception
+ * @param interfaceMethod the client interface method which invoked this request
+ * @return a fallback object to be returned from the client interface method
+ * @throws Throwable an exception which will be thrown from the client interface method
+ */
+ Object propagateOrFallback(RetrofitError cause, Method interfaceMethod) throws Throwable;
@JakeWharton Owner

Remove the Method argument. Code generated implementations of interfaces won't have access.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@JakeWharton
Owner

I'm still not convinced this needs to go in. It seems like it's trying to place far too much logic inside Retrofit where the same could just as easily be accomplished with a more intelligent wrapper class.

interface FooService {
  @GET("/") String foo();
}
class FooServiceWrapper implements FooService {
  @Inject FooService delegate;
  @Inject @DefaultFoo String defaultFoo;

  @Override public String foo() {
    try {
      return delegate.foo();
    } catch (RetrofitError e) {
      Response r = e.getResponse();
      if (r != null && r.getStatus() == 404) {
        return defaultFoo;
      }
      throw e;
    }
  }
}
@adriancole

thanks for the advice @JakeWharton I'll use the wrapper approach until/unless this goes in.

@adriancole

@JakeWharton @swankjesse on this note.. sounds like we should add an "idea graveyard" wiki and/or issue tag.

@sberan

@JakeWharton I definitely see your point, but it feels like exception behavior is a similar level of abstraction to return value / parameter conversion. It would be nice to not have to wrap all APIs with wrapper just to handle errors, when everything else can be handled via conversion.

@sberan

I removed the Method parameter from the ErrorHandler interface.

@adriancole

yeah the downside of the wrapper approach is it highlights a mix of abstractions. For example, our return val is a domain-specific object, yet we need to deal with http-specific exceptions.

@adriancole

FWIW, I'm cool with using wrapper to do fallbacks so we can at least support application-specific exceptions in retrofit. How about if we rewind to something like:

interface ErrorHandler {
   Throwable handle(RetrofitError error);
}
@JakeWharton
Owner

I think that's a reasonable starting point (and it'll work with code generation!). Generating a more-specific error type is definitely useful when dealing with the synchronous API.

@sberan sberan Add hook for customizing exceptions
The exceptions can be customized via a new ErrorHandler class, which is
responsible for returning customized exceptions during synchronous
requests.
054d165
@sberan

@JakeWharton fantastic to hear. I really like how this ended up, API wise. I've changed the ErrorHandler interface to return an exception to propagate.

Out of curiosity, what was the issue with code generation compatibility?

@JakeWharton
Owner

What happens if I throw a checked exception in the handler that wasn't declared on the interface method?

@JakeWharton JakeWharton merged commit 054d165 into square:master
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on May 26, 2013
  1. @sberan

    Add hook for customizing exceptions

    sberan authored
    The exceptions can be customized via a new ErrorHandler class, which is
    responsible for returning customized exceptions during synchronous
    requests.
This page is out of date. Refresh to see the latest.
View
27 retrofit/src/main/java/retrofit/ErrorHandler.java
@@ -0,0 +1,27 @@
+package retrofit;
+
+
+/**
+ * A hook allowing clients to customize error exceptions for synchronous
+ * requests.
+ *
+ * @author Sam Beran sberan@gmail.com
+ */
+public interface ErrorHandler {
+ /**
+ * Return a custom exception to be thrown for this RetrofitError instance.
+ *
+ * If the exception is checked, any returned exceptions must be declared to be
+ * thrown on the interface method.
+ *
+ * @param cause the original RetrofitError exception
+ * @return Throwable an exception which will be thrown from the client interface method
+ */
+ Throwable handleError(RetrofitError cause);
+
+ ErrorHandler DEFAULT = new ErrorHandler() {
+ @Override public Throwable handleError(RetrofitError cause) {
+ return cause;
+ }
+ };
+}
View
25 retrofit/src/main/java/retrofit/RestAdapter.java
@@ -18,7 +18,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
@@ -121,12 +120,13 @@
private final RequestHeaders requestHeaders;
private final Converter converter;
private final Profiler profiler;
+ private final ErrorHandler errorHandler;
private final Log log;
private volatile boolean debug;
private RestAdapter(Server server, Client.Provider clientProvider, Executor httpExecutor,
Executor callbackExecutor, RequestHeaders requestHeaders, Converter converter,
- Profiler profiler, Log log, boolean debug) {
+ Profiler profiler, ErrorHandler errorHandler, Log log, boolean debug) {
this.server = server;
this.clientProvider = clientProvider;
this.httpExecutor = httpExecutor;
@@ -134,6 +134,7 @@ private RestAdapter(Server server, Client.Provider clientProvider, Executor http
this.requestHeaders = requestHeaders;
this.converter = converter;
this.profiler = profiler;
+ this.errorHandler = errorHandler;
this.log = log;
this.debug = debug;
}
@@ -159,7 +160,7 @@ public void setDebug(boolean debug) {
@SuppressWarnings("unchecked") //
@Override public Object invoke(Object proxy, Method method, final Object[] args)
- throws InvocationTargetException, IllegalAccessException {
+ throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
@@ -177,7 +178,11 @@ public void setDebug(boolean debug) {
}
if (methodDetails.isSynchronous) {
- return invokeRequest(methodDetails, args);
+ try {
+ return invokeRequest(methodDetails, args);
+ } catch (RetrofitError error) {
+ throw errorHandler.handleError(error);
+ }
}
if (httpExecutor == null || callbackExecutor == null) {
@@ -401,6 +406,7 @@ private Response logAndReplaceResponse(String url, Response response, long elaps
private RequestHeaders requestHeaders;
private Converter converter;
private Profiler profiler;
+ private ErrorHandler errorHandler;
private Log log;
private boolean debug;
@@ -471,6 +477,12 @@ public Builder setProfiler(Profiler profiler) {
return this;
}
+ public Builder setErrorHandler(ErrorHandler errorHandler) {
+ if (errorHandler == null) throw new NullPointerException("error handler cannot be null");
+ this.errorHandler = errorHandler;
+ return this;
+ }
+
/** Configure debug logging mechanism. */
public Builder setLog(Log log) {
if (log == null) throw new NullPointerException("log");
@@ -491,7 +503,7 @@ public RestAdapter build() {
}
ensureSaneDefaults();
return new RestAdapter(server, clientProvider, httpExecutor, callbackExecutor, requestHeaders,
- converter, profiler, log, debug);
+ converter, profiler, errorHandler, log, debug);
}
private void ensureSaneDefaults() {
@@ -507,6 +519,9 @@ private void ensureSaneDefaults() {
if (callbackExecutor == null) {
callbackExecutor = Platform.get().defaultCallbackExecutor();
}
+ if (errorHandler == null) {
+ errorHandler = ErrorHandler.DEFAULT;
+ }
if (log == null) {
log = Platform.get().defaultLog();
}
View
67 retrofit/src/test/java/retrofit/ErrorHandlerTest.java
@@ -0,0 +1,67 @@
+package retrofit;
+
+import org.junit.Before;
+import org.junit.Test;
+import retrofit.client.Client;
+import retrofit.client.Request;
+import retrofit.client.Response;
+import retrofit.http.GET;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.fest.assertions.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ErrorHandlerTest {
+
+ interface ExampleClient {
+ @GET("/") Response throwsCustomException() throws IllegalStateException;
+ }
+
+
+ /* An HTTP client which always returns a 400 response */
+ static class MockInvalidResponseClient implements Client {
+ @Override
+ public Response execute(Request request) throws IOException {
+ return new Response(400, "invalid request", Collections.<retrofit.client.Header>emptyList(), null);
+ }
+ }
+
+
+ ExampleClient client;
+ ErrorHandler errorHandler;
+
+ @Before
+ public void setup() {
+ errorHandler = mock(ErrorHandler.class);
+
+ client = new RestAdapter.Builder()
+ .setServer("http://example.com")
+ .setClient(new MockInvalidResponseClient())
+ .setErrorHandler(errorHandler)
+ .setExecutors(new Utils.SynchronousExecutor(), new Utils.SynchronousExecutor())
+ .build()
+ .create(ExampleClient.class);
+ }
+
+
+ @Test
+ public void testCustomizedExceptionThrown() throws Throwable {
+ when(errorHandler.handleError(any(RetrofitError.class)))
+ .thenThrow(new IllegalStateException("invalid request"));
+
+ try {
+ client.throwsCustomException();
+ fail();
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage()).isEqualTo("invalid request");
+ }
+ }
+
+}
Something went wrong with that request. Please try again.