Skip to content

interceptor: reject a request from its head before the body is read - #318

Draft
iainmcgin wants to merge 1 commit into
mainfrom
interceptor-head-hook
Draft

iainmcgin wants to merge 1 commit into
mainfrom
interceptor-head-hook

Conversation

@iainmcgin

Copy link
Copy Markdown
Collaborator

Interceptors run after the request body is read (unary) or after a body reader has started (streaming), so the only way to reject a request before any body byte is read was Tower middleware, which cannot see the resolved Spec.

Interceptor::intercept_head(&self, head: &mut RequestHead<'_>) runs for every registered interceptor, in registration order, before the body is read or a reader starts, on unary, streaming, gRPC unary and Connect GET requests. The first Err is returned to the client in the protocol's error format and the body is never read: an HTTP/2 stream is reset and an HTTP/1.x connection is usually closed, as for a Tower rejection. The default accepts every request, so existing interceptors are unchanged.

async fn intercept_head(&self, head: &mut RequestHead<'_>) -> Result<(), ConnectError> {
    let token = head.header("authorization").and_then(|v| v.to_str().ok());
    let Some(caller) = token.and_then(|t| self.verify(t)) else {
        return Err(ConnectError::unauthenticated("missing or invalid token"));
    };
    head.extensions_mut().insert(Caller(caller));
    Ok(())
}

RequestHead::spec() is None for an unknown path. Values inserted with extensions_mut() reach later interceptors and the handler.

The head is &mut so an authentication check can pass the caller on; adding mutability later would break every implementor.

All head checks run before any intercept_unary or intercept_streaming, so they see the headers as they reached the service, not as an outer interceptor rewrote them, and an outer interceptor never sees a head rejection. The hook runs before the request deadline applies, so it has no time limit. The guide now lists intercept_head next to Tower middleware as a place for authentication.

Requests rejected for an unsupported method or unrecognized content type never reach interceptors, and their body is still drained under max_request_body_size and the request deadline. Each registered interceptor allocates one boxed future per request for this method, even if it does not override it.

Adds `Interceptor::intercept_head`, which receives a `RequestHead` (path,
resolved `Spec`, headers, protocol, request extensions) before the server
reads any of the body or starts a body reader. It runs for every registered
interceptor in registration order, on unary, streaming, gRPC unary and
Connect GET requests, and the first error is returned to the client in the
protocol's error format without the body being read. An HTTP/2 stream is
reset and an HTTP/1.x connection is usually closed, as for a Tower layer that
returns a response without calling the service.

Interceptors previously ran after the body was read (unary) or after the body
reader had started (streaming), so only Tower middleware could reject early,
and middleware cannot see the resolved `Spec`. The new method defaults to
accepting every request, so existing interceptors are unchanged.

A head check can insert values with `RequestHead::extensions_mut`, which
later interceptors and the handler read from the request context, so an
authentication check can pass the caller on. `RequestHead::new` builds a head
for unit tests, and `peer_addr` / `peer_certs` read the connection's address
and client certificates.

Requests the service rejects for an unsupported HTTP method or an unrecognized
content type still never reach interceptors.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin
iainmcgin requested a review from rpb-ant September 22, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant