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

Non-IOException subtypes thrown from interceptor never notify Callback #5151

Open
JakeWharton opened this issue May 31, 2019 · 16 comments · Fixed by #5457
Open

Non-IOException subtypes thrown from interceptor never notify Callback #5151

JakeWharton opened this issue May 31, 2019 · 16 comments · Fixed by #5457
Labels
bug Bug in existing code

Comments

@JakeWharton
Copy link
Collaborator

A programming error in an interceptor or in OkHttp or an error of any kind will bypass the Callback and crash the Dispatcher thread leaving your consumer hanging forever. This was reported to Retrofit's Kotlin coroutine support, but is not specific to it.

My proposal is for adding something like

default void onFailure(Call call, Throwable t) {
  if (t instanceof IOException) {
    onFailure(call, (IOException) t);
  }
}

and deprecating the existing onFailure callback in a 3.14.3 or 3.15 and the equivalent added to 4.0. This will force Retrofit onto Java 8, although given the added safety I think that's acceptable.

@swankjesse
Copy link
Collaborator

It works for continuations where unexpected exceptions can be caught, but I dislike it otherwise. There’s nothing reasonable to do in onFailure().

@JakeWharton
Copy link
Collaborator Author

That is a dangerous assumption. Continuations are just sugar over callbacks. There's nothing specific to them that isn't also true of Rx or futures or promises or this callback. There's plenty you can do when a RuntimeException is the result of an async invocation. Imagine if the synchronous API just blocked your thread forever on anything but an IOException.

@swankjesse
Copy link
Collaborator

Counterproposal!

I agree that we should offer a mechanism for callers to release resources after an unexpected exception. But I don't like sending expected IOExceptions and unexpected NullPointerException/OutOfMemoryError/AssertionError failures to the same place. It risks sweeping serious bugs under the rug.

When a Call suffers an unexpected exception, let's do this:

  1. Cancel the call by delivering IOException("canceled") or similar to the callback.
  2. Deliver the actual failure to the UncaughtExceptionHandler.

This keeps the contract between caller and OkHttp simple: you get a response or an exception you can mostly ignore. We say, “OkHttp will cancel calls for you if there is an unexpected exception”

And it allows the real problem to be dealt with, by crashing the app or whatever has been configured.

@JakeWharton
Copy link
Collaborator Author

Canceling the call is a very elegant approach. I like it a lot.

Would you put the underlying exception as the cause?

@swankjesse
Copy link
Collaborator

I think we don’t add it as a cause because it could get double-reported in tracking tools.

@yschimke
Copy link
Collaborator

yschimke commented Jun 1, 2019

You can add suppresses then?

@yschimke yschimke added this to the 4.0 milestone Jun 1, 2019
@Tolriq
Copy link
Contributor

Tolriq commented Jun 1, 2019

1000 :) Tried a few times to ask for something allowing handling of non IOException errors and always rejected.
Currently using added interceptor for that ...

Typical use on Android would be security issues for rooted user who by default block Internet permission, not crashing and having the exception allows to properly notify the user.

    private class OkHttpExceptionInterceptor : Interceptor {

        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain): Response {
            try {
                return chain.proceed(chain.request())
            } catch (e: Throwable) {
                if (e is IOException) {
                    throw e
                } else {
                    throw IOException(e)
                }
            }
        }
    }

@swankjesse
Copy link
Collaborator

Lets do this for 4.1.

@JakeWharton
Copy link
Collaborator Author

Any chance it can land in a 3.x for Retrofit's sake as well? I'm not quite ready to do the Kotlin dance yet.

@swankjesse
Copy link
Collaborator

Yuppers.

@danielesegato
Copy link

@JakeWharton @swankjesse thanks for answer in square/retrofit#3505

This is currently the implementation in okhttp

    override fun run() {
      threadName("OkHttp ${redactedUrl()}") {
        var signalledCallback = false
        timeout.enter()
        try {
          val response = getResponseWithInterceptorChain()
          signalledCallback = true
          responseCallback.onResponse(this@RealCall, response)
        } catch (e: IOException) {
          if (signalledCallback) {
            // Do not signal the callback twice!
            Platform.get().log("Callback failure for ${toLoggableString()}", Platform.INFO, e)
          } else {
            responseCallback.onFailure(this@RealCall, e)
          }
        } catch (t: Throwable) {
          cancel()
          if (!signalledCallback) {
            val canceledException = IOException("canceled due to $t")
            canceledException.addSuppressed(t)
            responseCallback.onFailure(this@RealCall, canceledException)
          }
          throw t
        } finally {
          client.dispatcher.finished(this)
        }
      }
    }

the catch(t: Throwable) part is what I'm not happy about, but I admit I don't fully understand it. Like for instance I do not understand why you call cancel() there and why you can't instead just wrap the exception into an IOException and forward it like you do in the catch (e: IOException) block.

I suppose if you use OkHttp directly it's not a big issue, you are writing the code for each Http request and can easily wrap/unwrap exception yourself

I think this is more an issue at the Retrofit level than of OkHttp level: wrapping the Interceptor is easier than wrapping a retrofit service that is auto-generated / handled by retrofit.

With RxJava2 retrofit interface:

interface MyApi {
  @GET("whatever")
  fun get(): Single<Response<MyBody>>
}

if an interceptor throw an exception that is not IOException the Single get canceled and the onFailure callback ignored.

which means that if an Rx observer is waiting for something on it it will never receive a callback and will stay hanging there, the error is not even logged.

Using kotlin coroutines the behavior is different

interface MyApi {
  @GET("whatever")
  suspend fun get(): Response<MyBody>
}

In this case the coroutines is, again, canceled but the app crashes with the exception

I believe the reason is here:

suspend fun <T> Call<T>.awaitResponse(): Response<T> {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        continuation.resume(response)
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

there no isActive check in onFailure before calling resumeWithException(t) and since the coroutine has been already canceled here it crash the app.

In #5457 says

It would also be nice if integrations could unwrap this. What about using a specific subtype of IOException so the Rx and Coroutine code in Retrofit can correctly unwrap this and propagate the real, crash-worthy cause?

I 100% agree with this.

But I believe that if you call cancel() there's no way for the exception to reach retrofit adapters (and have a chance to be unwrapped)

I believe the minimal impact option for a developer right now is to write a custom CallFactory that wraps rebuild an OkHttpClient instance wrapping every interceptor into a Delegate interceptor that just catches and wrap non-IOExceptions, than use a custom CallAdapter to do the unwrapping.

But I believe this is not possible if you use suspend fun cause they are build-in into retrofit. So the only other option I could think of is to manually unwrap exceptions above the retrofit service or writing a Proxy, except I'm not sure if there is any implication of writing a Proxy of a Kotlin class.

Sorry if I'm wasting your time, just trying to help myself and other developers stumbling into this.

@yschimke yschimke reopened this Dec 23, 2020
@JakeWharton
Copy link
Collaborator Author

why you can't instead just wrap the exception into an IOException and forward it like you do in the catch (e: IOException) block.

Because it's not related to I/O. You'd be implying that it's a transport problem when it's actually a programming problem.

But I believe this is not possible if you use suspend fun cause they are build-in into retrofit

It still goes through the call adapter machinery. There's a test which demonstrates it that shouldn't be too hard to find.

@danielesegato
Copy link

Because it's not related to I/O. You'd be implying that it's a transport problem when it's actually a programming problem.

yes, I know.

I liked your original proposal of just changing OkHttp onFailure interface giving it a default and deprecating the other one.

Without it's either hide the exception / crash the app outside of the call or wrap/unwrap into an IOException.

Since it looks like you can't change the interface I was hoping there was a way to wrap / unwrap the exception instead.

What about turning the behavior into something configurable in OkHttp builder so that new versions of Retrofit or other libraries could take advantage of it without breaking backward compatibility?

@JakeWharton
Copy link
Collaborator Author

I don't like the idea of a knob to change the behavior. In Retrofit we don't control the OkHttpClient instance, for example.

I think I still prefer the default method approach as well. Although given the change that was already made I have no idea if it can still be compatibly added.

@danielesegato
Copy link

I don't like the idea of a knob to change the behavior.

I was thinking more like a pluggable handling for the catch(Throwable) part with a default implementation.

I think I still prefer the default method approach as well.

I also liked it more.

Although given the change that was already made I have no idea if it can still be compatibly added.

the default implementation could just rethrow and an external catch would do exactly what it does now.

as an alternative you could add a new interface and just check for it and use it if available.

I don't think there's a clean way to make this kind of change in an API without breaking backward compatibility or requiring an extra step.

M-Wong added a commit to DP-3T/dp3t-sdk-android that referenced this issue Mar 9, 2021
@swankjesse swankjesse removed this from the 4.3 milestone Jun 12, 2022
@yschimke
Copy link
Collaborator

@swankjesse if we want to do this, we should do it for 5.x, or close.

cc @JakeWharton

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Bug in existing code
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants