At Coinbase we use Retrofit and Square’s RxJava 2 Adapter as our API to the wire. Retrofit makes networking a breeze, but throughout our app we found ourselves writing code like this:
authApi.getTokens() .subscribe({ response: Response<AccessToken> -> when { response.isSuccessful && response.body() != null -> { // Case 1: Success. We got a response with a body. } response.errorBody() != null -> { // Case 2: Failure. We got an error from the backend, deserialize it. val error = moshi.adapter(Error::class.java).fromJson(errorBody.source()) } else -> { // Case 3: Failure. Response didn't have a body. Show a vanilla error. } } }, { t ->
Handling the result of a network call
This works, but there’s a few rough edges:
Our API isn’t declarative. To determine what state we’re in we have to null check a bunch of things and it’s easy to miss a case
We’ve inadvertently leaked our network serializer (
) into our application layer to deserialize error bodies
Streams get torn down when a network error occurs. This isn’t a big deal here, but if we start combining network streams with other Observables we likely don’t want network errors to terminate the resulting stream
Let’s look at how we can use Retrofit’s CallAdapter API to nerf down these edges. We’ll use Sealed Classes to represent the result of network calls and build error body deserialization into Retrofit.
authApi.getTokens() .subscribe { response : NetworkResponse<AccessToken, Error> -> when (response) { is NetworkResponse.Success<AccessToken> -> { // A 2XX response that's guaranteed to have a body of type AccessToken. } is NetworkResponse.ServerError<Error> -> { // A non-2XX response that may have an Error as its error body. } is NetworkResponse.NetworkError -> { // A request that didn't result in a response from the server. } } }
Sealed Classes + Typed Error Body Deserialization — Networking Nirvana
when theNetworkResponse
API is invoked, we have to write a customgetTokens()
. TheCallAdapter.Factory
below says, “I know how to create instances ofCallAdapter.Factory
that are emitted to RxJava streams."NetworkResponse
/** * A [CallAdapter.Factory] which allows [NetworkResponse] objects to be * returned from RxJava streams created by Retrofit. * * Adding this class to [Retrofit] allows you to write service methods like: * * fun getTokens(): Single<NetworkResponse<AccessToken,Error>> */ class KotlinRxJava2CallAdapterFactory : CallAdapter.Factory() {
override fun get( returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit ): CallAdapter<*, *>? {
// This adapter only handles creating RxJava streams. If the caller // isn't asking for an RxJava stream, return null, this isn't the right // adapter! val rawType = getRawType(returnType) val isFlowable = rawType === Flowable::class.java val isSingle = rawType === Single::class.java val isMaybe = rawType === Maybe::class.java if (rawType !== Observable::class.java && !isFlowable && !isSingle && !isMaybe ) { return null }
// Check to see if the RxJava stream is emitting instances of NetworkResponse. // If not this isn't the right adapter, return null! val observableEmissionType = getParameterUpperBound(0, returnType) if (getRawType(observableEmissionType) != NetworkResponse::class.java) { return null }
// Ask Retrofit for an adapter that's capable of creating an instance // of Observable<AccessToken> val successBodyType = getParameterUpperBound(0, observableEmissionType) val delegateType = Types.newParameterizedType( Observable::class.java, successBodyType ) val delegateAdapter = retrofit.nextCallAdapter( this, delegateType, annotations )
// Ask Retrofit for a serializer than can serialize an instance of Error val errorBodyType = getParameterUpperBound(1, observableEmissionType) val errorBodyConverter = retrofit.nextResponseBodyConverter<Any>( null, errorBodyType, annotations )
return KotlinRxJava2CallAdapter( successBodyType, delegateAdapter, errorBodyConverter, isFlowable, isSingle, isMaybe ) } }
API is invoked, thisgetTokens()
:CallAdapter.Factory
Delegates to an adapter that knows how to make an instance of the type
Observable<AccessToken>
(line 46)
Asks for a converter capable of serializing the type
Error
(line 54)
Creates a
KotlinRxJava2CallAdapter
(line 60). This adapter deserializes error bodies and will decorate the stream of
Observable<AccessToken>
into a
Single<NetworkResponse<AccessToken,Error>>
we have to plug it into ourCallAdapter.Factory
instance. Adapter registration order is important since we’ve written a delegatingRetrofit
; we must register our adapter before any other adapters it may delegate to. In the snippet below we delegate the creation ofCallAdapter
to Square’s RxJava Adapter.Observable<AccessToken>
val retrofit = Retrofit.Builder() .baseUrl("https://api.coinbase.com/v2/") .addCallAdapterFactory(KotlinRxJava2CallAdapterFactory()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build()
If you’re interested in having a Kotlin-esque API to the wire that provides error body deserialization, you can find the full code for our Adapter here.
P.S. We’re hiring!
Special thanks to
for catching a bug in the full code for the adapter; all error codes aren’t guaranteed to return JSON!
Product,
Oct 3, 2024