Skip to content

Instantly share code, notes, and snippets.

@quad
Last active April 10, 2024 09:06
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save quad/13e9f8b6e88dec1729e30b270ebf22df to your computer and use it in GitHub Desktop.
Save quad/13e9f8b6e88dec1729e30b270ebf22df to your computer and use it in GitHub Desktop.
Interceptors Are Functions Too

Interceptors Are Functions Too

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.

What is an Interceptor?

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.

An Interface Comparison

Object-Oriented

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')

Functional

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.

An Implementation Comparison

Object-Oriented

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)

Functional

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 Interceptors
  • 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 manually indexing into a List
  • We recurse into a single function (proceed) rather than creating a new RealInterceptorChain 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.

Other Useful Behaviours

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)

An Embedded Comparison

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 Pure Functional Comparison

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)
}

Closing Thoughts

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
package functors
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
data class Request(private val msg: String)
data class Response(private val msg: String)
typealias Effect = suspend (Request) -> Response
fun interface Interceptor : (Effect) -> Effect
fun printRequestInterceptor() = Interceptor { proceed ->
{
println("Intercepted outgoing request: [ $it ]")
proceed(it)
}
}
fun nonCancellableInterceptor() = Interceptor { proceed ->
{
withContext(NonCancellable) {
println("Entered non-cancellable section")
proceed(it)
}
}
}
fun useInterceptor(acquire: () -> AutoCloseable) = Interceptor { proceed ->
{ request ->
acquire().use {
proceed(request)
}
}
}
class Resource : AutoCloseable {
init {
println("Opened resource")
}
override fun close() = println("Closed resource")
}
fun main() {
val interceptors = listOf(
nonCancellableInterceptor(),
useInterceptor { Resource() },
printRequestInterceptor(),
)
val base: Effect = { _ -> Response("pong") }
val chain = interceptors.foldRight(base) { interceptor, acc -> interceptor(acc) }
val request = Request("ping")
val response = runBlocking {
chain.invoke(request)
}
println(response)
// Yields this output:
//
// Entered non-cancellable section
// Opened resource
// Intercepted outgoing request: [ Request(msg=ping) ]
// Closed resource
// Response(msg=pong)
}
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
data class Request(private val msg: String)
data class Response(private val msg: String)
fun interface Interceptor {
suspend fun invoke(
request: Request,
proceed: suspend (Request) -> Response
): Response
}
fun printRequestInterceptor() = Interceptor { request, proceed ->
println("Intercepted outgoing request: [ $request ]")
proceed(request)
}
fun baseInterceptor() = Interceptor { _, _ -> Response("pong") }
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)
}
}
class Resource : AutoCloseable {
init {
println("Opened resource")
}
override fun close() = println("Closed resource")
}
suspend fun proceed(
request: Request,
iterator: Iterator<Interceptor>
): Response =
iterator.next().invoke(request) {
proceed(it, iterator)
}
fun main() {
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)
}
import typing
from dataclasses import dataclass
from typing_extensions import Protocol
@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: ...
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")
@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)
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')
@teliosdev
Copy link

If I'm not mistaken, the tower library in Rust composes interceptors similar to the pure functional composition noted here - tower::Layer<S> is similar to (Effect) -> Effect, operating by wrapping the incoming "effect" (a "service" in tower's terminology) in another "effect." The end result is a single function.

@quad
Copy link
Author

quad commented Nov 28, 2023

If I'm not mistaken, the tower library in Rust composes interceptors similar to the pure functional composition noted here - tower::Layer<S> is similar to (Effect) -> Effect, operating by wrapping the incoming "effect" (a "service" in tower's terminology) in another "effect." The end result is a single function.

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. The Transform trait (almost identical to tower::Layer) was quickly introduced and exists even now in version 2.0.2.

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