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:
| Crate | Purpose |
|---|---|
connectrpc-axum | Server library - layers, extractors, response types |
connectrpc-axum-client | Client library - HTTP client, streaming, interceptors |
connectrpc-axum-core | Shared protocol types - compression, error codes, envelope framing |
connectrpc-axum-build | Build-time code generation from proto files |
The core modules in the client library:
| Module | Purpose |
|---|---|
client.rs | ConnectClient<I> - main client type, generic over interceptor chain |
builder.rs | ClientBuilder<I> - fluent API for client configuration |
config/interceptor.rs | Unified interceptor system (Interceptor, MessageInterceptor) |
transport/ | HTTP transport abstraction (HyperTransport) |
request.rs | Request encoding, FrameEncoder for streaming |
response.rs | Response types, Streaming<T>, FrameDecoder |
The core modules in the server library:
| Module | Purpose |
|---|---|
context/ | Protocol detection, compression config, message limits, timeouts |
message/{request,response}.rs | Request/response primitives (decode, encode, compress) |
layer/ | Middleware layers (BridgeLayer, ConnectLayer) |
message/ | ConnectRequest<T> extractor and ConnectResponse<T> wrapper |
handler.rs | Handler 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 ResponseBridgeLayer (outermost) - see layer/bridge.rs:
- Checks unary
Content-Lengthagainst 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:
RequestDecompressionLayerdecompresses unary request bodies,CompressionLayercompresses unary response bodies - Use standard
Accept-Encoding/Content-Encodingheaders
ConnectLayer (innermost) - see layer/connect.rs:
- Validates content-type and returns HTTP 415 for unsupported types
- Parses
Content-Typeto determine encoding (JSON/Protobuf) - Parses
?encoding=query param for GET requests - Validates
Connect-Protocol-Versionheader when required - Parses
Connect-Timeout-Msand applies one absolute deadline to handler execution and response streaming - Builds
ConnectContextand 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-Encodingheaders - Request decompression is handled by tower-http's
RequestDecompressionLayer, response compression by itsCompressionLayer - 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-Encodingheaders - BridgeLayer sets
Accept-Encoding: identityto 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 inconnectrpc-axum-core'scodec.rs;context/envelope_compression.rsre-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 itHandlers 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:
| Pattern | Request Type | Response Type |
|---|---|---|
| Unary | ConnectRequest<Req> | ConnectResponse<Resp> |
| Server streaming | ConnectRequest<Req> | ConnectResponse<StreamBody<St>> |
| Client streaming | ConnectRequest<Streaming<Req>> | ConnectResponse<Resp> |
| Bidi streaming | ConnectRequest<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 itThe "2-layer box" approach (same pattern axum uses for Handler → MethodRouter):
- Factory layer:
IntoFactorytrait producesBoxedCall<Req, Resp>- a type-erased callable - Wrapper layer:
TonicHandlerWrapperimplementsHandlerfor 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 mappingsPass 1.5: pbjson serde generation
File descriptor set → pbjson_build → {package}.serde.rs
→ Appended into Pass 1 filesThis 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 typesPass 3: Tonic client (optional)
File descriptor set → tonic_build → Client stubs
→ Uses extern_path to reference Pass 1 typesThe 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:
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
ConnectLayerfor protocol handling - Wrapping routes with
BridgeLayerfor compression bridging - Adding tower-http
RequestDecompressionLayerandCompressionLayerfor 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 RouterFor 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:
| Trait | Purpose | Use Case |
|---|---|---|
Interceptor | Header-level access only | Auth headers, trace IDs, logging procedure names |
MessageInterceptor | Typed message access | Validation, message transformation, per-message logging |
Both traits are wrapped internally to InterceptorInternal via adapter types:
HeaderWrapper<I>- wrapsInterceptorimplementationsMessageWrapper<I>- wrapsMessageInterceptorimplementations
This enables zero-cost composition via Chain<A, B> without dynamic dispatch.
Context Types
Interceptors receive context objects with relevant information:
| Type | Fields | Used In |
|---|---|---|
RequestContext | procedure, headers (mutable) | on_request |
ResponseContext | procedure, headers (read-only) | on_response |
StreamContext | procedure, stream_type, request_headers, response_headers | Streaming methods |
Builder API
ConnectClient and ClientBuilder use a single type parameter I for the interceptor chain (defaults to ()):
// 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)returnsClientBuilder<Chain<I, HeaderWrapper<J>>>with_message_interceptor(i)returnsClientBuilder<Chain<I, MessageWrapper<J>>>
Convenience Types
| Type | Purpose |
|---|---|
HeaderInterceptor | Add a single header to all requests |
ClosureInterceptor | Quick 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.