Advanced Topics
This page covers production-ready features for gRPCServer.jl: interceptors, TLS, compression, and more.
Ordinary servers are built on the codegen interface: one protojl run emits the message types and per-service registration functions, and you register handlers with the generated register_<Service>! / register_<Service>_<Rpc>! functions. The last section of this page, "The runtime interface beneath the codegen", explains the descriptor-based layer those generated functions sit on — reach for it only when you need to build or inspect services manually.
Interceptors
Interceptors allow you to add cross-cutting concerns like logging, authentication, and rate limiting. They work the same way whether the service was registered through the generated register_*! functions or the runtime interface.
Logging Interceptor
host = "127.0.0.1"port = 50051server = GRPCServer(host, port)add_interceptor!(server, LoggingInterceptor( log_requests = true, log_responses = true, log_errors = true))Metrics Interceptor
request_counter = Ref(0)latencies = Float64[]add_interceptor!(server, MetricsInterceptor( on_request = (method, size) -> begin request_counter[] += 1 end, on_response = (method, status, duration_ms, size) -> begin push!(latencies, duration_ms) end))Custom Authentication Interceptor
struct AuthInterceptor <: Interceptor valid_tokens::Set{String}endfunction (auth::AuthInterceptor)( ctx::ServerContext, request::Any, info::MethodInfo, next::Function) token = get_metadata_string(ctx, "authorization") if token === nothing || !(token in auth.valid_tokens) throw(GRPCError( StatusCode.UNAUTHENTICATED, "Invalid or missing authentication token" )) end return next(ctx, request)endadd_interceptor!(server, AuthInterceptor(Set(["token123", "token456"])))Rate Limiting Interceptor
mutable struct RateLimitInterceptor <: Interceptor requests_per_second::Int window_start::Float64 request_count::IntendRateLimitInterceptor(rps::Int) = RateLimitInterceptor(rps, time(), 0)function (rl::RateLimitInterceptor)( ctx::ServerContext, request::Any, info::MethodInfo, next::Function) now = time() if now - rl.window_start >= 1.0 rl.window_start = now rl.request_count = 0 end rl.request_count += 1 if rl.request_count > rl.requests_per_second throw(GRPCError( StatusCode.RESOURCE_EXHAUSTED, "Rate limit exceeded" )) end return next(ctx, request)endadd_interceptor!(server, RateLimitInterceptor(100))Health Checking
Health checks allow load balancers and orchestrators to monitor server status.
server = GRPCServer(host, port; enable_health_check = true)# Set overall server healthset_health!(server, HealthStatus.SERVING)# Set health for specific serviceset_health!(server, "my.Service", HealthStatus.SERVING)# Mark service as not ready (e.g., during maintenance)set_health!(server, "my.Service", HealthStatus.NOT_SERVING)Testing Health Check
grpcurl -plaintext -d '{"service": ""}' localhost:50051 grpc.health.v1.Health/CheckExpected output:
{
"status": "SERVING"
}TLS Configuration
Basic TLS
tls_config = TLSConfig( cert_chain = "server.crt", private_key = "server.key")server = GRPCServer(host, port; tls = tls_config)Mutual TLS (mTLS)
tls_config = TLSConfig( cert_chain = "server.crt", private_key = "server.key", client_ca = "ca.crt", require_client_cert = true)server = GRPCServer(host, port; tls = tls_config)Hot Reloading Certificates
# Reload certificates without restarting serverreload_tls!(server)See the TLS page for a full walkthrough.
Compression
Server-side Compression (not yet implemented)
Send-side response compression is not yet implemented on any backend: responses are always sent with grpc-encoding: identity. The configuration keywords for it (compression_enabled=true, compression_threshold, supported_codecs) are recognized but inert, so setting them explicitly now raises UnsupportedFeatureError at construction instead of being silently ignored:
# Raises UnsupportedFeatureError on every backend — send-side compression# is not implemented yet.server = GRPCServer(host, port; supported_codecs = [CompressionCodec.GZIP, CompressionCodec.DEFLATE])Omit those keywords for now. Receive-side decompression works: the server accepts grpc-encoding: gzip/deflate requests and decompresses them (strictly on HTTPjlBackend, leniently on PureHTTP2Backend), so compressed clients are fully supported.
Manual Compression
using gRPCServer: compress, decompress, CompressionCodecdata = Vector{UInt8}("Large data to compress...")# Compress with GZIPcompressed = compress(data, CompressionCodec.GZIP)# Decompressoriginal = decompress(compressed, CompressionCodec.GZIP)Error Handling
Returning Specific Status Codes
function my_handler(ctx::ServerContext, request) if request.id < 0 throw(GRPCError( StatusCode.INVALID_ARGUMENT, "ID must be non-negative" )) end item = find_item(request.id) if item === nothing throw(GRPCError( StatusCode.NOT_FOUND, "Item not found: $(request.id)" )) end if !has_permission(ctx, item) throw(GRPCError( StatusCode.PERMISSION_DENIED, "Access denied to item $(request.id)" )) end return itemendError Details
throw(GRPCError( StatusCode.INVALID_ARGUMENT, "Multiple validation errors", Any[ Dict("field" => "email", "error" => "Invalid email format"), Dict("field" => "age", "error" => "Must be positive") ]))Context Usage
Accessing Metadata
function my_handler(ctx::ServerContext, request) # Get string metadata auth = get_metadata_string(ctx, "authorization") # Get binary metadata (keys ending in -bin) trace = get_metadata_binary(ctx, "x-trace-bin") # Check remaining time before deadline remaining = remaining_time(ctx) if remaining !== nothing && remaining < 1.0 @warn "Less than 1 second remaining" end return responseendSetting Response Headers and Trailers
function my_handler(ctx::ServerContext, request) # Set response header set_header!(ctx, "x-request-id", string(ctx.request_id)) # Set trailer (sent at end of response) set_trailer!(ctx, "x-processing-time", "50ms") return responseendClient Streaming
A service that receives multiple requests and returns a single response, registered with the generated codegen function:
function sum_numbers(ctx::ServerContext, stream::ClientStream{NumberRequest}) total = 0 for request in stream total += request.value end return SumResponse(total = total)end# do-block per-RPC formregister_Math_Sum!(server) do ctx, stream total = 0 for request in stream total += request.value end SumResponse(total = total)endBidirectional Streaming
A chat-like service with two-way streaming:
function chat(ctx::ServerContext, stream::BidiStream{ChatMessage, ChatMessage}) for message in stream if is_cancelled(ctx) break end # Echo back with prefix response = ChatMessage( user = "Server", text = "You said: $(message.text)" ) send!(stream, response) end close!(stream) return nothingend# do-block per-RPC formregister_Chat_Chat!(server) do ctx, stream for message in stream send!(stream, ChatMessage("Server", "You said: $(message.text)")) end close!(stream) return nothingendPassing Application State
Attach application state to the server and read it from any handler through ctx.payload:
struct AppState dbendserver = GRPCServer("127.0.0.1", 50051; context = AppState(open_db()))register_MyService_GetThing!(server) do ctx, req Thing(query(ctx.payload.db, req.id))endThe context keyword accepts any value; it is threaded untouched into every request's ServerContext.payload. Handlers never construct the state themselves, which makes them easy to test in isolation.
Concurrency Model
The server is built on HTTP.jl, which spawns one task per inbound HTTP/2 stream. That per-stream task is where your handler runs. Because each connection and each stream is independent, many RPCs are served concurrently as a matter of course.
The max_concurrent_requests keyword on GRPCServer (default 1024) caps how many RPCs run at once. When the cap is reached, additional requests are shed immediately with a trailers-only StatusCode.RESOURCE_EXHAUSTED status — there is no queue and no waiting. Pass nothing or 0 for an unlimited cap (the pre-1.0 default).
server = GRPCServer("127.0.0.1", 50051; max_concurrent_requests = 256)Handler thread safety is the application's responsibility: two handlers can execute simultaneously on different threads and share whatever you attached as context. Guard mutable shared state (database connection pools, caches, counters) with the appropriate locks or atomics. For CPU-bound work, launch Julia with --threads=auto; with a single thread, tasks still interleave cooperatively at I/O boundaries, but CPU-bound handlers will not run in parallel.
Production Hardening
Authentication and authorization
Authentication and authorization are the application's responsibility. The server does not authenticate callers. A handler reads credentials from request metadata and rejects the call with the appropriate status:
function my_handler(ctx::ServerContext, request) token = get_metadata_string(ctx, "authorization") if token === nothing || !is_valid(token) throw(GRPCError(StatusCode.UNAUTHENTICATED, "Missing or invalid credentials")) end if !authorized(token, request) throw(GRPCError(StatusCode.PERMISSION_DENIED, "Access denied")) end return responseendTransport
Use TLS in production. The default is cleartext HTTP/2 (h2c); pass tls = TLSConfig(...) to serve h2. Cleartext should only be used behind a trusted boundary, for example a localhost sidecar or a TLS-terminating proxy. See TLS.
Concurrency cap
max_concurrent_requests ships enabled with a conservative default of 1024 (HTTP.jl allows 100 concurrent streams per connection, so without a cap N connections imply 100·N concurrent handler tasks). Still size it explicitly to the host's memory and the configured max_message_size in production; a cap that is too small sheds legitimate load with RESOURCE_EXHAUSTED, while one that is too large lets a flood consume all available memory.
Connection timeouts
Connection timeouts default to read_header_timeout = 30 seconds, which reaps slow-header connections without disturbing established streams, and idle_timeout = 300 seconds, which closes connections that stop sending bytes (including one holding a partial request body, bounding slow-body memory accrual). read_timeout and write_timeout are disabled by default; enabling them defends against a peer that trickles or never finishes a request or response body, but a non-zero read_timeout also terminates legitimately idle long-lived streaming RPCs, so set it only for unary or short-lived workloads.
Deadlines and cancellation
The grpc-timeout header is parsed strictly into ctx.deadline (a malformed value fails the call with INVALID_ARGUMENT). The deadline is enforced at two points, and never while your handler is running:
- Pre-dispatch: if the deadline has already passed when the request reaches
the handler (a zero timeout, or queueing delay past the deadline), the call fails fast with a trailers-only DEADLINE_EXCEEDED and your handler is never invoked.
- Post-return: once your handler returns, a deadline that has since passed
maps the result to DEADLINE_EXCEEDED (a status your handler already produced as DEADLINE_EXCEEDED/CANCELLED is left untouched).
A handler that runs past its deadline is not interrupted — it runs to completion, and only then is its result mapped. There is no watchdog-based cancellation yet. Handlers that must bound their own runtime should check remaining_time(ctx) / is_cancelled(ctx) cooperatively (the gRPC interceptors work here too — see TimeoutInterceptor, which is also pre-check-only):
function my_handler(ctx::ServerContext, request) remaining = remaining_time(ctx) if remaining !== nothing && remaining <= 0 throw(GRPCError(StatusCode.DEADLINE_EXCEEDED, "Deadline exceeded")) end # ... work, checking is_cancelled(ctx) between long stepsendA long-running handler that ignores the deadline is a resource-exhaustion vector: it ties up a stream (and memory) for as long as it runs. Pair cooperative deadline checks with a max_concurrent_requests cap sized to your workload, and keep idle_timeout enabled, so that a flood of slow or never-completing calls cannot pile up unbounded handlers.
The Runtime Interface Beneath the Codegen
The generated register_<Service>_<Rpc>! functions build a MethodDescriptor and call register_method! on the server's dispatcher. You normally never do this by hand, but the pieces are public so custom registration flows are possible.
Descriptor builders
Each *_Method builder creates a MethodDescriptor for one RPC:
method = MyService_GetThing_Method((ctx, req) -> Thing(req.id))# raw variants: receive/return undecoded Vector{UInt8} payloadsmethod = MyService_GetThing_Method((ctx, raw) -> raw; raw_request = true, raw_response = true)A raw response is the protobuf message body only — the framing layer still applies — so whatever bytes a raw handler returns must decode as a well-formed protobuf of the response type on the client. An echo handler like the one above only works when the client expects exactly those bytes (for example a raw client); a typed client decoding a different message type will fail.
Manual service registration
Build a ServiceDescriptor and register it with register!, or register a single method with register_method!:
struct MyService endfunction gRPCServer.service_descriptor(::MyService) ServiceDescriptor( "pkg.MyService", Dict( "GetThing" => MyService_GetThing_Method((ctx, req) -> Thing(req.id)) ), nothing )endregister!(server, MyService())# or, for a single method:register_method!(server.dispatcher, "pkg.MyService", MyService_GetThing_Method((ctx, req) -> Thing(req.id)))register_method! is what the generated register_*! functions call internally. See the API Reference for the full surface (MethodDescriptor, ServiceDescriptor, register!, register_method!).
Graceful Shutdown
server = GRPCServer(host, port)register_Greeter!(server; SayHello = say_hello)# Run in background taskserver_task = @async run(server; block = true)# Later, initiate graceful shutdown@info "Shutting down..."stop!(server; timeout = 30.0)# Wait for server to stopwait(server_task)@info "Server stopped"Next Steps
- API Reference - Complete API documentation
- Quick Start - Getting started guide
- Code Generation - The codegen interface in detail