I could not agree more with my colleague and friend Travis Johnson's opinion that "INTERCEPTORS ARE SO COOL!" In that post, he succinctly describes the Interceptor pattern as used adroitly by OkHttp. But, as is often the case, I believe a complicated object-oriented pattern obscures the simple functional gem within it.
I'll quote liberally from OkHttp's documentation on the topic:
Interceptors are a powerful mechanism that can monitor, rewrite, and retry calls. […]
Interceptors can be chained. Suppose you have both a compressing interceptor and a checksumming interceptor: you'll need to decide whether data is compressed and then checksummed, or checksummed and then compressed. OkHttp uses lists to track interceptors, and interceptors are called in order.
Travis boiled down the interfaces to nice minimal example in Python which I'll tweak just a little:
@dataclass
class Request:
msg: str
@dataclass
class Response:
msg: str
class Chain(Protocol):
def proceed(self, request: Request) -> Response: ...
request: Request
class Interceptor(Protocol):
def intercept(self, chain: Chain) -> Response: ...
With only these two interfaces (Chain
and Interceptor
), the useful behaviours we can compose are limited only by our imagination!
His minimal demonstration, thankfully, sticks to the basics:
class PrintRequestInterceptor(Interceptor):
def intercept(self, chain: Chain) -> Response:
print("Intercepted outgoing request: [", chain.request, "]")
return chain.proceed(chain.request)
class BaseInterceptor(Interceptor):
def intercept(self, chain: Chain) -> Response:
return Response("pong")
request = Request("ping")
interceptors = [
PrintRequestInterceptor(),
BaseInterceptor(),
]
chain = RealInterceptorChain(request, interceptors)
response = chain.proceed(request)
print(response)
# Yields this output:
#
# Intercepted outgoing request: [ Request(msg='ping') ]
# Response(msg='pong')
I'll note that OkHttp is a networking library for HTTP written in Kotlin. That's a nice launching off point, because over the last few months, I've been (re)writing a networking library for NFC also written in Kotlin. Thus, I'm going to first translate the above interfaces to a functional approach written in— you guessed it!— Kotlin.
data class Request(private val msg: String)
data class Response(private val msg: String)
fun interface Interceptor {
fun invoke(request: Request, proceed: (Request) -> Response): Response
}
Wait— what happened to Chain
? Wellll, it's been subsumed into the Interceptor
interface.
Let's take a look at how the minimal demonstration of these interfaces change too:
fun printRequestInterceptor() = Interceptor { request, proceed ->
println("Intercepted outgoing request: [ $request ]")
proceed(request)
}
fun baseInterceptor() = Interceptor { _, _ -> Response("pong") }
val interceptors = listOf(
printRequestInterceptor(),
baseInterceptor(),
)
val request = Request("ping")
val response = proceed(request, interceptors.iterator())
println(response)
// Yields this output:
//
// Intercepted outgoing request: [ Request(msg=ping) ]
// Response(msg=pong)
To my eyes, both the interfaces and the use of them are almost identical! The real difference is within the thus far elided RealInterceptorChain
/ proceed
implementations. Let's dig into those now.
I've again tweaked and simplified Travis's implementation in the interests of both clarity and fairness:
@dataclass
class RealInterceptorChain(Chain):
request: Request
interceptors: typing.List[Interceptor]
index: int = 0
def proceed(self, request: Request) -> Response:
next_chain = RealInterceptorChain(request, self.interceptors, self.index + 1)
interceptor = self.interceptors[self.index]
return interceptor.intercept(next_chain)
fun proceed(request: Request, iterator: Iterator<Interceptor>): Response =
iterator.next().invoke(request) {
proceed(it, iterator)
}
The crux of the implementations are the same:
- They both iterate over
Interceptor
s - They both recursively call into
proceed
The key difference is the lift we get out of two Functional Programming Things:
- We pass an
Iterator
relying on interior mutability rather than manuallyindex
ing into aList
- We recurse into a single function (
proceed
) rather than creating a newRealInterceptorChain
and calling into it
N.b. that Python has an iterator protocol; and it's trivial to tweak the object-oriented example to use it.
The reason I dug into all of this is I had a real problem to solve.
In the NFC library I mentioned before, there are system resources that need to be carefully managed. For example, exclusive access to a mobile phone's radio. Additionally, some resources are intended to be scoped to particular points in the NFC command/response lifecycle. For example, spans for use in distributed tracing. Moreover, our runtime is highly asynchronous and relies heavily on the use of suspendable coroutines.
None of these mix well with the object-oriented approach. And since we're in Kotlin and the above interceptors are classes, any dependency injection needs to occur either via a constructor (which cannot be suspended) or a property. Cards on the table: we are not a property injection house.
Let's look at a minimal demonstration of both resource management and dependency injection without classes!
First, let's tweak the Interceptor
interface to be suspend
:
fun interface Interceptor {
suspend fun invoke(request: Request, proceed: suspend (Request) -> Response): Response
}
Accordingly, proceed
becomes a suspend
function. I won't bother you with that one-word difference.
Now let's introduce two new interceptors:
fun nonCancellableInterceptor() = Interceptor { request, proceed ->
withContext(NonCancellable) {
println("Entered non-cancellable section")
proceed(request)
}
}
fun useInterceptor(acquire: () -> AutoCloseable) =
Interceptor { request, proceed ->
acquire().use {
proceed(request)
}
}
Let's also make a stub AutoCloseable
resource for demonstration purposes:
class Resource : AutoCloseable {
init {
println("Opened resource")
}
override fun close() = println("Closed resource")
}
Finally, to tie it all together, here's our updated use of the above:
val interceptors = listOf(
nonCancellableInterceptor(),
useInterceptor { Resource() },
printRequestInterceptor(),
baseInterceptor(),
)
val request = Request("ping")
val response = runBlocking {
proceed(request, interceptors.iterator())
}
println(response)
// Yields this output:
//
// Entered non-cancellable section
// Opened resource
// Intercepted outgoing request: [ Request(msg=ping) ]
// Closed resource
// Response(msg=pong)
A common problem in kernel mode or embedded systems is that multiple subsystems need to know when an interrupt fires (e.g. a timer tick or a buffer being ready).
- Memory allocation is a no-no because allocation (or freeing) takes a variable amount of time, which would cause the processor to be late in servicing other— potentially urgent or time sensitive— interrupts.
- The total amount of available stack may not be larger than a page. By way of comparison, the minimal system call stack size for the Cortex-M33 in my current project is 128 bytes. I once wrote an ethernet and IP stack for the SH-4 with a page size limit of 1024 bytes. And the processor on which I learned to program— the 65C02 in the Apple IIc— had a total stack of 256 bytes! All that to say, recursion needs to be carefully controlled otherwise we will corrupt memory.
Surprisingly, if you squint, the Interceptor pattern works in these constrained environments too.
Here's code I dug up from the archives:
typedef void (* anim_render_chain_f) (uint16);
This is a pointer for a handler function that takes a 16-bit unsigned integer. (What is that integer? Not important— but it's a code for what animation mode the system was in)
Now, how do we register an interceptor?
static anim_render_chain_f old_anim_chain;
static void my_anim_chain(uint16 anim_code) {
printf_debug("Test module active.");
if (old_anim_chain)
return old_anim_chain(anim_code);
}
void module_configure (void) {
anim_add_render_chain(my_anim_chain, &old_anim_chain);
}
What's happened here is the module has declared a single and static place in memory to hold the pointer to the next interceptor in the chain. When the module is configured, it calls anim_add_render_chain
to have its interceptor put at the beginning of the chain and the previous top of the chain is saved in old_anim_chain
.
Let's look at anim_add_render_chain
just so we know nothing magic is happening under the covers:
static anim_render_chain_f anim_render_chain;
void anim_add_render_chain(anim_render_chain_f new_function, anim_render_chain_f *old_function) {
/* Swap around the functions, and give them back the original for passthrough. */
*old_function = anim_render_chain;
anim_render_chain = new_function;
}
How does the chain get serviced?
static void* anim_trap_handler(register_stack *stack, void *current_vector) {
if (ubc_trap_number() == TRAP_CODE_ANIM) {
/* If this is our trap, ensure the original code is emulated. */
stack->r4 = *ANIM_MODE;
if (anim_render_chain)
anim_render_chain(*ANIM_MODE);
}
/* Pass back to a further trap handler, if one exists. */
if (old_trap_handler)
return old_trap_handler (stack, current_vector);
else
return current_vector;
}
Surprise! The animation chain is a sub-chain from the animation subsystem's interception of a more general chain for the SH-4's UBC. (User Break Controller, page 342, knock yourself out)
It's interceptor chains all the way down!
A monad is just a monoid in the category of endofunctors, what's the problem?
The functional programming cool kids stopped reading back when I claimed the above rephrase of Travis's code was "functional." No monads? Not even a mention of functors? What are we even doing here?! But, imperative code is easier to read and I'm tired of pretending it's not.
But. But! They have a point when it comes to clarity and reuse. We can get all the flexibility of the object-oriented approach, the brevity of the "functional" approach, and the determinism of the embedded approach in one go:
Let's start with the interfaces again:
fun interface Endofunction<T> : (T) -> T
fun interface Suspendable<A, B> : Endofunction<suspend (A) -> B>
fun interface Interceptor : Suspendable<Request, Response>
🤣 I’m joking! No one has ever written the word Endofunction
in real life.
typealias Effect = suspend (Request) -> Response
fun interface Interceptor : (Effect) -> Effect
In short, an interceptor is a function that takes a function and returns a function; a higher-order function. And you'll notice there's no mention of proceed
!
How do we define an interceptor then, in this approach?
fun printRequestInterceptor() = Interceptor { proceed ->
{
println("Intercepted outgoing request: [ $it ]")
proceed(it)
}
}
There's proceed
; welcome back old friend!
Finally, we tie all the above together:
val base: Effect = { _: Request -> Response("pong") }
val chain = interceptors.foldRight(base) { interceptor, acc -> interceptor(acc) }
To the best of my knowledge— and surely to the eternal frustration of the Arrow community— Kotlin does not support currying or apply out of the box. But we've composed the interceptors together into a single chain, ready to be called with a Request
. And there's no recursion!
val request = Request("ping")
val response = runBlocking {
chain.invoke(request)
}
Our production code is even more complicated by the perhaps overly liberal use of generic types. 🤷
And, I confess, I'm sure the Kotlin type system can be worked to make object-oriented dependency injection work with our mix of coroutines and resources. I imagine plumbing the depths of OkHttp would be enlightening. But, this is the way I found, resulting in fewer lines of code and the brutally kind guidance of autocomplete.
The esteemed Marvin Charles points out that hono, off in Javascript land, uses the (first) functional approach too. Is this validation? I don't envy its dispatch code.
But, in summary:
- INTERCEPTORS ARE SO COOL!
- Interceptors Are Functions Too
I believe you're right!
Interestingly, actix appears to have been originally influenced by Your Server as a Function and until version 0.2.0 used
andThen
combinators for its take on filters. TheTransform
trait (almost identical totower::Layer
) was quickly introduced and exists even now in version 2.0.2.