interceptors
- 10 minutes read - 2067 wordsINTERCEPTORS ARE SO COOL!
Sometimes you need some “generic rails” that are still highly adaptable to other uses. This is the basic problem solved by the Interceptor pattern.
I really love the way OkHttp does interceptors for the generic rails of making HTTP calls, so I wanted to walk through a case study of why an interceptor might be useful and then try to synthesize some lessons & a minimal example of the pattern.
OkHttp represents several data objects corresponding to several core concepts in HTTP:
Request
: a user-created data object containing details about the request being made. This is a combination of things like the URL, the HTTP method (POST
), any request body, any number of request headers.Call
: a client-managed object that has committed to making a certain HTTP query but which might be handled synchronously or asynchronously or canceled. They are a “preparedRequest
”.Connection
: a client-managed object representing the actual connection to a certain HTTP server. Used over potentially manyCall
s.Response
: a client-managed data object containing details about the response.
These objects are either passed to or returned from the OkHttpClient
object; here’s a (slightly embellished) simple example of a synchronous GET
call from the OkHttp Recipes page:
public class ExampleGet {
private static final OkHttpClient client = new OkHttpClient();
public static void main(String[] args) {
Request request = new Request.Builder()
.url("https://publicobject.com/helloworld.txt")
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful())
throw new IOException("Unexpected code " + response);
Headers responseHeaders = response.headers();
for (int i = 0; i < responseHeaders.size(); i++) {
System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i));
}
System.out.println(response.body().string());
}
}
}
You can see we construct a Request
representing the HTTP call we’d like to make, hand that to our OkHttpClient
instance to get back a Call
, and then #execute()
that to get a Response
back.
Behind the scenes, OkHttpClient
is managing the Connection
object (and a bunch of other details) for you.
I want you to imagine you’re writing some application that calls this code on a TON of different URLs.
You might be tempted to refactor the above implementation by extracting a doCall
method like
public static void main(String[] args) {
ExampleGet eg = new ExampleGet();
eg.doCall("https://publicobject.com/helloworld.txt");
eg.doCall("< ... many more urls ...>");
}
public String doCall(String url) {
Request request = new Request.Builder().url(url).build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful())
throw new IOException("Unexpected code " + response);
Headers responseHeaders = response.headers();
for (int i = 0; i < responseHeaders.size(); i++) {
System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i));
}
String result = response.body().string();
System.out.println(result);
return result;
}
}
That seems ok, but maybe eventually our application needs to do a call to a certain domain to establish that something doesn’t exist; this implies the
if (!response.isSuccessful())
throw new IOException("Unexpected code " + response);
is no longer appropriate, because we expect a 404!
We could just remove that check, and add it back at every call site that needs it. But then you live in fear of every diff you review that someone has forgotten to check!
We could add a boolean requireSuccess
parameter to our #doCall
method, but then all the call sites need to change.
A good IDE can probably make this relatively easy, but that doesn’t erase the fact that now our entire codebase has to be aware of our requireSuccess
shenanigans.
We could get around that by maintaining the existing doCall(String)
method by just delegating to our 2-arg method:
String doCall(String url) {
return doCall(url, true);
}
String doCall(String url, boolean requireSuccess) {
// ...
}
but let’s take a second to see if we can figure out a better way.
We originally said we only needed this behavior for a specific domain, so we don’t actually need to pass the requireSuccess
at all – we can compute it inside our #doCall
method:
Set<String> ERROR_DOMAINS = ImmutableSet.of(
"traviscj.com",
// ...
);
String doCall(String url) {
boolean requireSuccess = ERROR_DOMAINS.stream()
.anyMatch(url::contains);
// ...
}
So this is pretty cool: as long as our codebase uniformly uses our #doCall
method, we’re pretty solid, right?
Time marches onward: product requirements evolve, team members churn, application ownership changes hands a few times.
Eventually, somebody needs to do something else slightly different than our ExampleGet#doCall
method supports: maybe they need the async call pattern, maybe they need to add some special headers for authentication, maybe they want to remove a certain sensitive header from printing out into unsecured logs… who knows.
Maybe they just forgot about or were never taught about the ExampleGet
object.
Maybe they got tired of maintaining our ExampleGet
wrapper object, or got burned by its lack of tests… or got intimidated by the volume of already-existing tests, which have a bunch of twists and turns to get our HTTP request handling just right.
The end result tends to be the same:
Someone ends up throwing away the framework and just using OkHttpClient
directly again!
This might feel like a breakdown in the natural order of the universe, but really this might represent an improvement in the situation for our application maintainers:
OkHttp
probably has better docs than your crappy helper/wrapper object.OkHttp
probably has better tests than your crappy helper/wrapper object.- You can hire someone that already knows the
OkHttp
API and they can hit the ground running.
So we’re left with a bit of a paradox: How do we add behavior to the HTTP traffic flowing through our app without needing either:
- to explicitly apply that behavior at every call site, or…
- our own mastermind object that knows about every behavior any of our application’s traffic requires?
The answer is interceptors! It lets us write a simple bit of logic like
class RequireSuccessInterceptor implements okhttp3.Interceptor {
Set<String> ERROR_DOMAINS = ImmutableSet.of(
"traviscj.com",
// ...
);
public Response intercept(Chain chain) {
Request request = chain.request();
Response response = chain.proceed(request);
if (!isErrorDomain(request) && !response.isSuccessful()) {
throw new IOException("Unexpected code " + response);
}
return response;
}
boolean isErrorDomain(Request request) {
String domain = request.httpUrl().topPrivateDomain();
return ERROR_DOMAINS.contains(domain);
}
}
I haven’t defined Chain
yet, but we’ll discuss it in more detail as soon as we’ve savored what we accomplished here.
With RequireSuccessInterceptor
in hand, we can then configure our OkHttpClient
object to apply this behavior on all requests processed with something like
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new RequireSuccessInterceptor())
.build();
With just that, now anything using this instance of client
will automatically apply this behavior!
- We didn’t need to add the error handling at every call site!
- we didn’t need to introduce the wrapper object, so we don’t need to test that beast or train new teammates on it, they can simply read the
OkHttp
documentation! - We can write tests against just this logic – we don’t need to consider other strange interactions going on with other crazy functions that other teams needed to add to our wrapper object.
- We didn’t need to try to convince jwilson that this would be a good feature for
OkHttp
in general. - In fact, Jesse probably isn’t even aware of your interceptor implementation!
All of these are pretty great wins!
Interceptor
interface
As you probably guessed from the RequireSuccessInterceptor
, the Interceptor
& Chain
APIs come together into:
interface Interceptor {
Response intercept(Chain chain);
interface Chain {
Response proceed(Request request);
// context of current request:
Request request();
Call call();
Connection connection();
// settings for the current request:
int connectTimeoutMillis();
int readTimeoutMillis();
int writeimeoutMillis();
// override settings for the current request:
Chain withConnectTimeout(Integer timeout, TimeUnit unit);
Chain withReadTimeout(Integer timeout, TimeUnit unit);
Chain withWriteTimeout(Integer timeout, TimeUnit unit);
}
}
Note that Chain
consolidates all of the required details into a single object, which keeps details like exactly what those details are out of Interceptor
implementations that don’t need them.
If the benefit isn’t quite clear yet, imagine Interceptor#intercept
taking instances of Request
, Call
, and Connection
as explicit method arguments – it maybe wouldn’t be too terrible in this instance, since each of these nouns is well understood and likely to be stable over time, but is that always true?
Do you really want to force all of your details on every integrator/customer?
One aspect I find really fascinating about this is that the Request
/Connection
breakdown neatly scopes request-scoped data separately from “connection”/session-scoped data.
That is, it exposes data from multiple scopes without conflating/muddling them together.
I think it also does a good job of exposing an enormous breadth of information: we know details not just about the Request
we’re trying to send, but also about whether we’re using a proxy and details about the proxy when relevant, how the TLS handshake went down, what protocol we’re connecting over, and even the socket we’re sending data over.
This level of depth actually suffices for many critical/core OkHttp
functionalities to be implemented as Interceptor
s, like CallServerInterceptor
, ConnectInterceptor
, and CacheInterceptor
!
This combination of simplicity & completeness is probably worth emulating in your own Chain
objects!
Chain
also has another super-important job: it needs to manage the order of application of each Interceptor
bound into our OkHttpClient
instance.
This might not quite become clear until you investigate the implementation in RealInterceptorChain
.
RealInterceptorChain
Distilling the RealInterceptorChain
logic down, basically we need to do several things:
- loop over each
Interceptor
in theList<Interceptor>
passed to the constructor. - maintain copies of any objects that the
Interceptor
s need – here, this includesCall
/Connection
/Request
and the assorted timeouts. - maintain just enough state to enforce some invariants, like “all interceptors are called” or “network interceptors must be called exactly once”.
- emit helpful error messages if any violations of those invariants are detected.
- enforce any other important invariants, like “interceptors must return response bodies”.
The implementation of this is pretty clever:
override fun proceed(request: Request): Response {
check(index < interceptors.size)
calls++
if (exchange != null) {
check(exchange.finder.sameHostAndPort(request.url)) {
"network interceptor ${interceptors[index - 1]} must retain the same host and port"
}
check(calls == 1) {
"network interceptor ${interceptors[index - 1]} must call proceed() exactly once"
}
}
// Call the next interceptor in the chain.
val next = copy(index = index + 1, request = request)
val interceptor = interceptors[index]
val response = interceptor.intercept(next) ?: throw NullPointerException(
"interceptor $interceptor returned null")
if (exchange != null) {
check(index + 1 >= interceptors.size || next.calls == 1) {
"network interceptor $interceptor must call proceed() exactly once"
}
}
check(response.body != null) { "interceptor $interceptor returned a response with no body" }
return response
}
minimal example
Finally, let’s see if we can make up a minimal example!
I’m going to drop Call
/Connection
but introduce a couple placeholders for Req
and Resp
, with both just holding a simple message:
class Req:
def __init__(self, msg):
self.msg = msg
def __repr__(self):
return f"Req(msg={self.msg})"
class Resp:
def __init__(self, msg):
self.msg = msg
def __repr__(self):
return f"Resp(msg={self.msg})"
In this simpler world, our Chain
object only needs two methods:
class Chain:
def proceed(self, req: Req) -> Resp: pass
def req(self) -> Req: pass
and finally, our Interceptor
object just needs to take a Chain
and return a Resp
:
class Interceptor:
def intercept(self, chain: Chain) -> Resp: pass
We can implement a simple Interceptor
just to have one ready for testing:
class PrintReqInterceptor(Interceptor):
def intercept(self, chain: Chain) -> Resp:
print("Intercepted outgoing request: [", chain.req(), "]")
return chain.proceed(chain.req())
and finally we can implement a RealInterceptorChain
:
class RealInterceptorChain(Chain):
def __init__(self, req: Req, interceptors: typing.List[Interceptor], index: int = 0, strict: bool = False):
self._req = req
self.interceptors = interceptors
self.index = index
self.strict = strict
self.calls = 0
def req(self) -> Req:
return self._req
def proceed(self, req: Req) -> Resp:
if self.index >= len(self.interceptors): raise Exception("broken recursion")
self.calls += 1
if self.strict:
if self.calls != 1: raise Exception("multiple calls to #proceed not allowed")
next_chain = RealInterceptorChain(req, self.interceptors, self.index + 1, self.strict)
interceptor = self.interceptors[self.index]
response = interceptor.intercept(next_chain)
if not response: raise Exception("interceptor returned None")
if self.strict:
if not (self.index + 1 >= len(self.interceptors) or next_chain.calls == 1):
raise Exception("bad recursion")
return response
and some Interceptor
that knows how to actually transform Req
into Resp
; I’ll use a static value to illustrate, though of course a real implementation would almost certainly need to use chain
to compute the actual return value:
class BaseInterceptor(Interceptor):
def intercept(self, chain: Chain) -> Resp:
return Resp("pong")
With all of that in hand, we’re ready to roll! This setup
req = Req("ping")
interceptors = [
PrintReqInterceptor(),
BaseInterceptor(),
]
rc = RealInterceptorChain(req, interceptors)
resp = rc.proceed(req)
print(resp)
will yield this output:
Intercepted outgoing request: [ Req(msg=ping) ]
Resp(msg=pong)
just as we wanted!