Skip to content

Connect-RPC Server

pkg/http/connect runs a Connect-RPC server as a SyncModule. One net/http handler serves Connect, gRPC-Web, and gRPC on a single port — cleartext HTTP/2 (h2c) lets gRPC clients connect without TLS.

Register services with WithService. The registrar runs during Init (when the injector is in context) and receives the shared interceptor chain to pass into the Connect constructor.

lakta.NewRuntime(
config.NewModule(
config.WithConfigDirs(".", "./config"),
config.WithArgs(os.Args[1:]),
),
tint.NewModule(),
slog.NewModule(),
otel.NewModule(),
connect.NewModule(
connect.WithService(func(ctx context.Context, opts []connectrpc.HandlerOption) (string, http.Handler) {
return greetv1connect.NewGreetServiceHandler(NewServer(), opts...)
}),
),
)

WithService is the recommended path: it defers handler construction to Init so you can resolve dependencies with lakta.Invoke[T](ctx), and it injects the built-in interceptor chain into your handler.

For a pre-built handler that already carries its own options, use WithHandler(path, handler). Note the shared interceptor chain is not applied to handlers registered this way — the handler is mounted as-is.

The interceptor chain is a fixed sequence of named slots (outer to inner). Every slot wraps unary and streaming handlers:

Slot Default
SlotOtel otelconnect tracing and metrics; remote span contexts are trusted
SlotRequestID accepts an inbound X-Request-Id only if it is a UUID or ULID, otherwise generates a ULID; puts it in ctx (RequestID(ctx)), the ctx logger and the response
SlotAccessLog one log line per finished call with procedure, code and duration
SlotErrorBoundary renders, logs and records every error from the slots below (see below)
SlotRecovery turns a handler panic into an error with the panic stack
SlotDeadline applies default_deadline to unary calls without one and caps them at max_deadline
SlotAuth, SlotAuthz empty; filled by auth modules or the application
SlotValidate protovalidate on every request message
SlotUser empty; WithInterceptors(...) appends after it

Replace or fill a slot with WithSlot(slot, factory). Factories run during Init, so they can resolve DI dependencies. Security interceptors must implement WrapStreamingHandler as well as WrapUnary.

WithRequiredSlots(SlotAuth, SlotAuthz) makes Init fail when a listed slot holds no interceptor, and when a handler is mounted outside the slot chain: any WithHandler entry, or a registrar whose path is not a protobuf service. Use it so a service cannot start without its security interceptors.

Every failed call is handled once, at the boundary:

  • Wire: the error is mapped through pkg/errors.FromError. For oops errors, an errors.Class* tag selects the status, Code() becomes ErrorInfo.Reason, In() becomes ErrorInfo.Domain, and Public() is the only text the client sees. oops context, stack and cause never reach the wire.
  • Log: one line with the full error (oops context and stack included), at ERROR for 5xx classes and WARN otherwise.
  • Trace: span.RecordError with error.code and error.domain; the span status is set to error for 5xx classes only.
var errNoteNotFound = oops.In("note").Code("NOTE_NOT_FOUND").Tags(errors.ClassNotFound).Public("note not found")

Before connect reads the request body, an HTTP middleware refuses GET to procedures unless allow_get is set, and for unary procedures caps the body at read_max_bytes and applies read_timeout. Streams are capped per message by read_max_bytes only. WithHTTPMiddleware adds your own middleware around the whole handler.

With WithTLSProvider (for example spiffe.ServerTLS()), the server requires client certificates and h2c is off. An HTTP middleware runs before the body is read: it takes the caller’s SPIFFE ID from the certificate URI SAN (PeerID(ctx)), refuses callers not in allowed_callers with PERMISSION_DENIED (an empty list refuses everyone), and believes X-Client-IP only from trusted_ip_callers (ClientIP(ctx)); the raw header never reaches handlers. Rejections and failed TLS handshakes emit security_event log lines. With require_peer_auth: true, a production profile refuses to start without mTLS or with an empty allowed_callers.

WithMountedHook receives the descriptors of every mounted protobuf service at the end of Init (for example to check that every RPC carries an authorization rule). A hook error fails Init. Paths that are not protobuf services are not passed to hooks; with required slots they fail Init instead.

pkg/http/connect/client (connectclient) builds clients for service-to-service calls. Pass Client.HTTPClient(), Client.BaseURL() and Client.Options() to a generated NewXxxClient.

  • mTLS: WithTLSProvider(spiffe.ClientTLS("spiffe://td/ns/x/sa/dbsvc")) accepts only the expected server.
  • Credentials per call, fail-closed: AsUser(ctx) forwards the inbound user token, AsService(ctx) uses OAuth2 client credentials (token_url, client_id, client_secret_file), WithToken(ctx, raw) uses an explicit token. A call without a choice fails before dialing.
  • Propagation: request ID, trace context and the verified client IP.
  • Deadline: min(inbound remaining, max_deadline).
  • Errors: downstream failures become UNAVAILABLE (transient codes) or INTERNAL; the downstream code and message never reach your caller. DownstreamCode(err) returns the original code for branching.

By default the server listens in plaintext with h2c enabled (WithH2C), so gRPC clients connect without TLS. Configure file-path based TLS to serve HTTP/2-over-TLS instead — when TLS is set, h2c is ignored.

connect.NewModule(
connect.WithName("internal"),
connect.WithService(internalRegistrar),
),
connect.NewModule(
connect.WithName("public"),
connect.WithPort(9090),
connect.WithService(publicRegistrar),
),

Config path: modules.http.connect.<name>

hoststringdefault: 0.0.0.0

host specifies the address for the server to bind to

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__HOST
portuint16default: 8080

port represents the port number on which the server listens

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__PORT
h2cbooldefault: true

h2C enables cleartext HTTP/2 (h2c) so gRPC clients work without TLS. When

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__H2C
read_timeouttime.Duration

readTimeout bounds reading the entire request incl. body for unary

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__READ_TIMEOUT
read_max_bytesint

readMaxBytes caps each request message (connect ResourceExhausted beyond

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__READ_MAX_BYTES
max_header_bytesint

maxHeaderBytes caps request header size (http.Server.MaxHeaderBytes)

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__MAX_HEADER_BYTES
default_deadlinetime.Duration

defaultDeadline is applied to unary calls whose client sent none

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__DEFAULT_DEADLINE
max_deadlinetime.Duration

maxDeadline caps unary client deadlines. Default 0 (no cap)

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__MAX_DEADLINE
allow_getbool

allowGet permits Connect GET requests (side-effect-free RPCs). Default

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__ALLOW_GET
allowed_callers[]string

allowedCallers lists the SPIFFE IDs allowed to call this server when peer

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__ALLOWED_CALLERS
trusted_ip_callers[]string

trustedIPCallers lists the SPIFFE IDs whose X-Client-IP header is believed

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__TRUSTED_IP_CALLERS
require_peer_authbool

requirePeerAuth makes mTLS and a non-empty AllowedCallers mandatory under

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__REQUIRE_PEER_AUTH
read_header_timeouttime.Durationdefault: 10s

readHeaderTimeout bounds reading request headers (maps to

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__READ_HEADER_TIMEOUT
tlsconfig.TLS

TLS configures file-path based transport security. When unset the server

envLAKTA_MODULES__HTTP__CONNECT__<NAME>__TLS

These options can only be set in Go code via With*() functions, not via config files or environment variables.

OptionTypeDescription
WithTLSProvider(...)builds the server TLS config during Init
WithHandler(...)map[string]http.Handlerregisters a pre-built (path, handler) pair directly; spreads a
WithService(...)[]connect.ServiceRegistraris the recommended registrar: it defers handler construction to Init
WithInterceptors(...)[]connect.Interceptorappends extra connect interceptors after every slot
WithSlot(...)map[connect.Slot]connect.InterceptorFactorysets the factory for a slot, replacing the built-in one if any
WithHTTPMiddleware(...)[]wraps the server handler; the first registered runs outermost
WithMountedHook(...)[]connect.MountedHookregisters a hook that receives the mounted services
WithRequiredSlots(...)[]connect.Slotmakes Init fail when a listed slot has no interceptor, and