Skip to content

Architecture

This page provides a conceptual overview of how connectrpc-axum works internally.

Overview

connectrpc-axum bridges the Connect protocol with Axum's handler model through multiple crates:

CratePurpose
connectrpc-axumServer library - layers, extractors, response types
connectrpc-axum-clientClient library - HTTP client, streaming, interceptors
connectrpc-axum-coreShared protocol types - compression, error codes, envelope framing
connectrpc-axum-buildBuild-time code generation from proto files

The core modules in the client library:

ModulePurpose
client.rsConnectClient<I> - main client type, generic over interceptor chain
builder.rsClientBuilder<I> - fluent API for client configuration
config/interceptor.rsUnified interceptor system (Interceptor, MessageInterceptor)
transport/HTTP transport abstraction (HyperTransport)
request.rsRequest encoding, FrameEncoder for streaming
response.rsResponse types, Streaming<T>, FrameDecoder

The core modules in the server library:

ModulePurpose
context/Protocol detection, compression config, message limits, timeouts
message/{request,response}.rsRequest/response primitives (decode, encode, compress)
layer/Middleware layers (BridgeLayer, ConnectLayer)
message/ConnectRequest<T> extractor and ConnectResponse<T> wrapper
handler.rsHandler wrappers that implement axum::handler::Handler
tonic/Optional gRPC interop and extractor support

Request Lifecycle

Layer Stack

Requests flow through a middleware stack (outermost to innermost):

HTTP Request

┌───────────────────────────────────────────────────────┐
│              BridgeLayer                              │  ← Size limits, streaming detection
│  ┌─────────────────────────────────────────────────┐  │
│  │     Tower RequestDecompressionLayer             │  │  ← Request body decompression (unary only)
│  │  ┌───────────────────────────────────────────┐  │  │
│  │  │     Tower CompressionLayer                │  │  │  ← Response body compression (unary only)
│  │  │  ┌─────────────────────────────────────┐  │  │  │
│  │  │  │         ConnectLayer                │  │  │  │  ← Protocol detection, context
│  │  │  │  ┌───────────────────────────────┐  │  │  │  │
│  │  │  │  │          Handler              │  │  │  │  │  ← Your RPC handlers
│  │  │  │  └───────────────────────────────┘  │  │  │  │
│  │  │  └─────────────────────────────────────┘  │  │  │
│  │  └───────────────────────────────────────────┘  │  │
│  └─────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────┘

HTTP Response

BridgeLayer (outermost) - see layer/bridge.rs:

  • Checks unary Content-Length against the receive limit before decompression
  • Detects Connect streaming requests (application/connect+*)
  • For streaming: prevents Tower compression by setting identity encoding

Tower RequestDecompressionLayer / CompressionLayer (middle):

  • Two distinct tower-http layers: RequestDecompressionLayer decompresses unary request bodies, CompressionLayer compresses unary response bodies
  • Use standard Accept-Encoding/Content-Encoding headers

ConnectLayer (innermost) - see layer/connect.rs:

  • Validates content-type and returns HTTP 415 for unsupported types
  • Parses Content-Type to determine encoding (JSON/Protobuf)
  • Parses ?encoding= query param for GET requests
  • Validates Connect-Protocol-Version header when required
  • Parses Connect-Timeout-Ms and applies one absolute deadline to handler execution and response streaming
  • Builds ConnectContext and stores it in request extensions

Compression Paths

The Connect protocol uses different compression mechanisms for unary vs streaming RPCs:

Unary RPCs - HTTP body compression:

Request → BridgeLayer (size check) → RequestDecompressionLayer (decompress) → ConnectLayer → Handler

Response ← BridgeLayer ← CompressionLayer (compress) ← ConnectLayer ← Handler response
  • Uses standard Accept-Encoding / Content-Encoding headers
  • Request decompression is handled by tower-http's RequestDecompressionLayer, response compression by its CompressionLayer
  • BridgeLayer checks compressed body size before decompression

Streaming RPCs - per-envelope compression:

Request → BridgeLayer (bypass Tower) → ConnectLayer → Handler

                                    Each message envelope compressed individually
  • Uses Connect-Accept-Encoding / Connect-Content-Encoding headers
  • BridgeLayer sets Accept-Encoding: identity to prevent Tower from interfering
  • Receive limits apply to each decompressed message envelope, not the aggregate streaming Content-Length
  • Codec implementations (GzipCodec, DeflateCodec, BrotliCodec, ZstdCodec) live in connectrpc-axum-core's codec.rs; context/envelope_compression.rs re-exports them and handles envelope-compression negotiation

Envelope flags are treated as a bitfield (COMPRESSED | END_STREAM); frames with unknown flag bits are rejected. Streaming decompression is bounded by receive_max_bytes via Codec::decompress_limited, so a compressed envelope cannot expand past the configured limit (see envelope.rs and codec.rs in connectrpc-axum-core). A client accepts an EndStream envelope only as the terminal frame and rejects bytes or transport errors after it.

Client Response Flow

For successful calls, the client requires HTTP 200 and the response media type for the selected JSON or Protobuf mode before decoding the body. Server-streaming and bidirectional calls retain the same absolute deadline while the response stream is consumed. If it expires, the stream returns deadline_exceeded and then terminates.

Code Structure

The request/response processing follows this module hierarchy:

context/           ← Configuration and protocol state
    protocol.rs        RequestProtocol enum, detection
    envelope_compression.rs    Per-message compression
    limit.rs           Message size limits
    timeout.rs         Request timeout

message/{request,response}.rs   ← Low-level encode/decode functions

layer/             ← Middleware that builds context
    bridge.rs          BridgeLayer/BridgeService
    connect.rs         ConnectLayer/ConnectService

message/           ← Axum extractors and response types
    request.rs         ConnectRequest<T>, Streaming<T>
    response.rs        ConnectResponse<T>, StreamBody<S>

Handlers receive a ConnectRequest<T> extractor that reads the ConnectContext from request extensions, then uses message/request.rs functions to decode the message. Response encoding uses message/response.rs in the reverse path.

Axum Extractor Support in Tonic Handlers

When using tonic-compatible handlers, axum's FromRequestParts extractors need access to HTTP request parts. The challenge: tonic consumes the HTTP request before your handler runs.

The solution is FromRequestPartsLayer in tonic/parts.rs:

HTTP Request

FromRequestPartsLayer ← Clones method, uri, version, headers into extensions

Tonic gRPC Server    ← Consumes HTTP request, but extensions survive

Your Handler         ← Reconstructs RequestContext from:
                        - CapturedParts (from extensions)
                        - extensions (from tonic::Request)

Key insight: http::Extensions cannot be cloned, but it can be moved. The layer captures clonable parts (CapturedParts), and the handler later combines them with the owned extensions to build a complete RequestContext for extraction.

Code Generation

ConnectHandlerWrapper

The ConnectHandlerWrapper<F> type transforms user functions into axum-compatible handlers. It's a wrapper struct around the handler function (plus PhantomData for the request/response types), with multiple impl Handler<T, S> blocks, each with different trait bounds:

User function: async fn(E1, E2, ..., ConnectRequest<Req>) -> ConnectResponse<Resp>
               where E1, E2, ... : FromRequestParts

            ConnectHandlerWrapper<F> implements Handler<T, S>

                        Axum can route to it

Handlers can include any types implementing FromRequestParts before the ConnectRequest<T> parameter, just like regular axum handlers. The compiler selects the appropriate impl based on the handler signature:

PatternRequest TypeResponse Type
UnaryConnectRequest<Req>ConnectResponse<Resp>
Server streamingConnectRequest<Req>ConnectResponse<StreamBody<St>>
Client streamingConnectRequest<Streaming<Req>>ConnectResponse<Resp>
Bidi streamingConnectRequest<Streaming<Req>>ConnectResponse<StreamBody<St>>

Bidirectional streaming requires HTTP/2 or later. HTTP/1.x requests are rejected with HTTP 505 before the request body is read.

See handler.rs for the implementation.

Tonic-Compatible Handlers

For tonic-style handlers (trait-based), the library uses a factory pattern with boxed calls:

User handler: async fn(ConnectRequest<Req>) -> Result<ConnectResponse<Resp>, ConnectError>

            IntoFactory trait converts to BoxedCall

            TonicHandlerWrapper adapts to axum Handler

                        Axum can route to it

The "2-layer box" approach (same pattern axum uses for HandlerMethodRouter):

  1. Factory layer: IntoFactory trait produces BoxedCall<Req, Resp> - a type-erased callable
  2. Wrapper layer: TonicHandlerWrapper implements Handler for the boxed call

One caveat: axum uses a trait for the factory layer, while we use closures. See this discussion for the design rationale.

This allows generated code to work with user-provided trait implementations without knowing concrete types at compile time. See tonic/handler.rs for the boxed call types and factory traits.

When Connect and gRPC handlers share error paths, conversion between ConnectError and tonic::Status preserves application metadata and google.rpc.Status details, including each Any type URL. Protocol-owned headers are filtered instead of being copied into application metadata.

Multi-Stage Code Generation

Code generation uses staged passes to avoid type duplication while keeping serde and tonic output aligned:

Pass 1: Prost + Schema + Connect

proto files → prost_build → Message types (Rust structs)
                          → File descriptor set

File descriptor set → internal prost-centric schema normalization
                    → Connect service builders
                    → Tonic extern_path mappings

Pass 1.5: pbjson serde generation

File descriptor set → pbjson_build → {package}.serde.rs
                                  → Appended into Pass 1 files

This pass also covers proto files without a package declaration.

Pass 2: Tonic server (optional)

File descriptor set → tonic_build → Server traits/stubs
                                  → Uses extern_path to reference Pass 1 types

Pass 3: Tonic client (optional)

File descriptor set → tonic_build → Client stubs
                                  → Uses extern_path to reference Pass 1 types

The key is extern_path: tonic passes don't regenerate message types, they reference Pass 1 output using the build crate's internal prost-compatible schema resolution. Well-known protobuf types are mapped to pbjson_types across Prost, pbjson, and tonic by default. Custom Prost extern mappings still need matching pbjson mappings through with_pbjson_config.

See CompileBuilder in connectrpc-axum-build for the type-state pattern that enforces valid configurations at compile time.

MakeServiceBuilder

MakeServiceBuilder combines multiple services and applies cross-cutting infrastructure:

rust
let app = MakeServiceBuilder::new()
    .add_router(hello_service_router)      // Connect service
    .add_router(echo_service_router)       // Another Connect service
    .add_grpc_service(grpc_server)         // Tonic gRPC service
    .add_axum_router(health_router)        // Plain axum routes (bypass ConnectLayer)
    .build();

The builder handles:

  • Wrapping Connect routes with ConnectLayer for protocol handling
  • Wrapping routes with BridgeLayer for compression bridging
  • Adding tower-http RequestDecompressionLayer and CompressionLayer for HTTP body decompression/compression
  • Routing gRPC services through ContentTypeSwitch (by Content-Type header)
  • Passing plain axum routes through without Connect processing
User provides:
    ├── Connect routers (from generated builders)
    ├── gRPC services (tonic)
    └── Plain axum routers

MakeServiceBuilder adds:
    ├── BridgeLayer
    ├── RequestDecompressionLayer + CompressionLayer
    ├── ConnectLayer (for Connect routes only)
    └── ContentTypeSwitch (routes gRPC vs Connect)

Output: Single axum Router

For mixed Connect/gRPC deployments, ContentTypeSwitch routes by Content-Type header:

  • application/grpc* → Tonic gRPC server
  • Otherwise → Axum routes (Connect protocol)

See service_builder.rs for the implementation.

Client Interceptor System

The client provides a unified interceptor system for cross-cutting concerns like authentication, logging, and message transformation.

Trait Hierarchy

Two user-facing traits, both internally unified:

TraitPurposeUse Case
InterceptorHeader-level access onlyAuth headers, trace IDs, logging procedure names
MessageInterceptorTyped message accessValidation, message transformation, per-message logging

Both traits are wrapped internally to InterceptorInternal via adapter types:

  • HeaderWrapper<I> - wraps Interceptor implementations
  • MessageWrapper<I> - wraps MessageInterceptor implementations

This enables zero-cost composition via Chain<A, B> without dynamic dispatch.

Context Types

Interceptors receive context objects with relevant information:

TypeFieldsUsed In
RequestContextprocedure, headers (mutable)on_request
ResponseContextprocedure, headers (read-only)on_response
StreamContextprocedure, stream_type, request_headers, response_headersStreaming methods

Builder API

ConnectClient and ClientBuilder use a single type parameter I for the interceptor chain (defaults to ()):

rust
// No interceptors
let client = ConnectClient::builder("http://localhost:3000")
    .build()?;

// With header-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
    .with_interceptor(AuthInterceptor::new("Bearer token"))
    .build()?;

// With message-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
    .with_message_interceptor(LoggingInterceptor)
    .build()?;

// Chaining multiple interceptors
let client = ConnectClient::builder("http://localhost:3000")
    .with_interceptor(AuthInterceptor::new("Bearer token"))
    .with_message_interceptor(ValidationInterceptor)
    .with_interceptor(TracingInterceptor)
    .build()?;

Each with_interceptor or with_message_interceptor call wraps the interceptor and composes it with the existing chain:

  • with_interceptor(i) returns ClientBuilder<Chain<I, HeaderWrapper<J>>>
  • with_message_interceptor(i) returns ClientBuilder<Chain<I, MessageWrapper<J>>>

Convenience Types

TypePurpose
HeaderInterceptorAdd a single header to all requests
ClosureInterceptorQuick header-level interception via closure

Execution Order

For requests, interceptors run in the order added (first added = first to run). For responses, interceptors run in reverse order (middleware unwinding pattern).

For streaming calls, header interceptors receive on_response once after the client accepts the response status and content type, even when the stream has no messages. Message interceptors still run per message, and StreamContext contains the finalized request headers sent on the wire together with the accepted response headers.

See config/interceptor.rs for the implementation.

Released under the MIT License.