Misk Interceptors: Cross-Cutting Concerns Without the Magic


Series: Building Production Services with Misk — Part 7 of 24

Logging, metrics, tracing, auth, load-shedding — the concerns that wrap every request rather than living inside one action. In Spring you’d reach for filters, HandlerInterceptors, and AOP advice, three different mechanisms. Misk has one concept, the misk interceptor, borrowed straight from OkHttp’s interceptor chain, and it comes in exactly two flavors that correspond to whether you want to work on raw bytes or decoded objects. Understanding the split is the whole post.

Two layers: network vs application

Misk runs an inbound request through a chain, and you can hook it at two levels:

  • A NetworkInterceptor operates on the encoded HTTP message — raw headers and the body as bytes. It runs first, before the request body is decoded into your action’s parameter types.
  • An ApplicationInterceptor operates on decoded value objects — it runs after the body has been turned into the typed arguments your action expects.

The interfaces are tiny, which tells you how much Misk trusts the OkHttp pattern. Here they are, verbatim:

interface NetworkInterceptor {
  fun intercept(chain: NetworkChain)
  interface Factory { fun create(action: Action): NetworkInterceptor? }
}

interface ApplicationInterceptor {
  fun intercept(chain: Chain): Any
  interface Factory { fun create(action: Action): ApplicationInterceptor? }
}

Both follow the chain idiom: do your work, call chain.proceed(...) to continue, do more work on the way back out. Skip the proceed call and you short-circuit the request entirely. The choice between the two is simply: do I need the bytes, or the decoded arguments?

NetworkInterceptor: working on bytes

Use a NetworkInterceptor when you need the raw HTTP message — decompressing a body, inspecting headers before anything is parsed, or rejecting a request before it costs you a deserialization. Here’s the built-in gunzip interceptor from the docs, which decompresses inbound bodies:

class GunzipRequestBodyInterceptor : NetworkInterceptor {
  override fun intercept(chain: NetworkChain) {
    val httpCall = chain.httpCall
    val contentEncoding = httpCall.requestHeaders[CONTENT_ENCODING]
      ?: return chain.proceed(httpCall)
    if (contentEncoding.lowercase() == GZIP) {
      httpCall.takeRequestBody()?.let {
        httpCall.putRequestBody(GzipSource(it).buffer())
      }
    }
    chain.proceed(httpCall)
  }
}

The key power here is short-circuiting: a network interceptor can decline to call proceed and end the request immediately. That’s how Misk’s concurrency-limiting (load-shedding) interceptor sheds traffic when it predicts a timeout, and how requests with missing auth headers get rejected before they ever reach an action. One caveat the docs are blunt about: by the time proceed() returns, the response has already been encoded and sent, so a network interceptor can rewrite the request but generally can’t rewrite the response.

ApplicationInterceptor: working on decoded arguments

Use an ApplicationInterceptor when you want the typed view — the action, its decoded arguments, and its return value. You can’t touch raw bytes here (the body’s already parsed), but you can read and even rewrite the arguments in a type-safe way:

class RequestBodyLoggingInterceptor : ApplicationInterceptor {
  override fun intercept(chain: Chain): Any {
    val result = chain.proceed(chain.args)
    log("args: ${chain.args}, response: $result")
    return result
  }
}

The chain here is richer than the network one: it carries the action, the function, the decoded args, and the HttpCall. That makes this the right layer for anything that reasons about what the action is and what it was asked to do rather than the wire format. Misk’s own access-control enforcement is an ApplicationInterceptor — which is exactly the subject of Part 9.

The Factory: opt in per action

Here’s the part that makes interceptors composable rather than global. You don’t register an interceptor directly; you register a Factory, and Misk calls create(action) once per action. Return an interceptor to attach it to that action, or return null to skip it:

class TimingInterceptorFactory @Inject constructor() : ApplicationInterceptor.Factory {
  override fun create(action: Action): ApplicationInterceptor? {
    // Attach only to actions you care about; null means "not for this one".
    return if (action.hasAnnotation<Timed>()) TimingInterceptor() else null
  }
}

And — as with every Misk feature — you wire it in with a Guice multibinding (the pattern from Part 3):

multibind<ApplicationInterceptor.Factory>().to<TimingInterceptorFactory>()

multibind for application factories, the matching NetworkInterceptor.Factory multibinding for network ones. The factory indirection is what lets an interceptor be selective: it can inspect the action’s annotations and decide whether to apply, so “log request bodies only on actions marked @Loggable” is a one-line create decision rather than a runtime check inside every request.

Ordering, inbound and outbound

On the way in, network interceptors run before application interceptors — bytes get massaged, then the body is decoded, then the typed layer runs. Interceptors also exist for outbound calls (your service calling others via Retrofit or the Wire gRPC client), and there the ordering is reversed: application interceptors run first, then network. The outbound variants use OkHttp’s own Interceptor type and are multibound via ClientApplicationInterceptorFactory and ClientNetworkInterceptor.Factory. Unlike inbound, outbound interceptors can rewrite the response — handy for injecting a missing header on a flaky upstream.

What Misk already gives you

Most of what you’d reach for is already built in and running. The inbound network chain ships with metrics, tracing, structured request logging, exception handling, concurrency limiting, and request-context logging, among others. The point isn’t to memorize the list — it’s to realize that metrics and tracing on every endpoint are not something you wire up; they’re interceptors Misk installs for you. Your custom interceptors slot into the same chain alongside them. When you reach the observability post (Part 22), you’ll see those built-ins are why metrics “just appear.”

Production notes & gotchas

  • Pick the layer by what you need. Bytes/headers/short-circuit → NetworkInterceptor. Decoded args/return value/per-action logic → ApplicationInterceptor. Reaching for the wrong one means fighting the framework.
  • Network interceptors can’t rewrite the inbound response. By the time proceed() returns, it’s encoded and sent. If you need to shape the response, do it at the application layer or in the action.
  • Return null from a factory to skip. That’s the idiomatic “this interceptor doesn’t apply here.” Don’t build a global interceptor that no-ops internally for most actions — let the factory decline.
  • Order matters and it’s implicit. Inbound is network-then-application; outbound reverses it. If two interceptors interact (one sets context the other reads), make sure they’re on layers that run in the order you assume.
  • Don’t reimplement the built-ins. Metrics, tracing, request logging, and load-shedding already exist as interceptors. Writing your own metrics interceptor usually means you missed the one Misk ships.

What’s next

Interceptors are the pipeline; now let’s serve a different protocol through it. In Part 8: gRPC & Protobuf in Misk we’ll define proto services, generate server interfaces with Wire, implement gRPC actions, and see how Misk auto-exposes a JSON variant of every protobuf endpoint.


Target keywords: misk interceptor, NetworkInterceptor, ApplicationInterceptor.

Comments