Core concepts
Gortexa’s design rests on four ideas: Protobuf as the single source of truth, one h2c port multiplexing three protocols, a fixed eight-stage interceptor chain, and one error-mapping table shared by gRPC, HTTP and MCP. Each is covered below.
Contract-first
Section titled “Contract-first”Protobuf is the single source of truth. You write two things: proto contracts and logic implementations. gRPC stubs, the HTTP/JSON gateway, the OpenAPI spec and MCP tool schemas are all produced by gortexa regen, which always runs buf lint → buf breaking → buf generate.
- Everything under
gen/is generated: committed (CI checks it for drift), never hand-edited. - The breaking-change gate is built into the flow: proto edits that break compatibility are rejected; additive edits pass.
Single h2c port multiplexing
Section titled “Single h2c port multiplexing”One cleartext HTTP/2 port, dispatched by Content-Type and path:
| Request | Destination |
|---|---|
Content-Type: application/grpc |
gRPC (grpc-go’s ServeHTTP mode) |
Path /mcp |
MCP bridge (Streamable HTTP) |
| Everything else | grpc-gateway (HTTP/JSON) and built-in routes (/healthz, /openapi.json) |
h2c uses Go’s native http.Server.Protocols, not the deprecated x/net/h2c. Cleartext means TLS must be terminated in front of the service in production — see Deployment.
The interceptor chain
Section titled “The interceptor chain”Eight stages, fixed order:
recover → request-id → logger → load-shed → rate-limit → circuit-breaker → auth → validationAn incomplete chain fails startup (fail-loud) rather than silently dropping a stage, and an absent one is refused outright: kernel.New requires exactly one of WithInterceptors or WithoutInterceptors, so a server with no recover, auth, rate limit or validation can no longer happen by omission. OTel attaches as a gRPC StatsHandler, covering unary and streaming. A panic recovered by the recover stage is logged with its request_id (client-supplied or minted by the chain, matching the X-Request-Id response header) — the only record a panicking RPC leaves.
The order is fixed, but the auth stage is pluggable: the default is HS256 JWT (interceptor.Config.Verifier), yet the stage actually runs an auth.Authenticator — a non-JWT scheme (static bearer, mTLS, API key) just sets Config.Authenticator, leaving the eight-stage order intact. To add your own interceptor, use kernel.WithServerOptions(...) (gRPC chains it after the framework chain, before the handler, still within recover) rather than editing the eight stages. Interceptor and StatsHandler options work as described, but transport-level options — KeepaliveParams, KeepaliveEnforcementPolicy, MaxStreams, InitialWindowSize — are silently ignored on the shared h2c port, because external gRPC there runs in grpc-go’s ServeHTTP handler mode, which builds its transport from the http.Request. They do still apply to the in-process loopback. If you need keepalive enforcement on your public gRPC surface, split gRPC onto its own listener.
The defaults for the three governance stages are defined in cmd/server/main.go (not in config.yaml — to change them, edit the interceptor.NewSet parameters and recompile):
| Stage | Defaults | On limit |
|---|---|---|
| rate-limit | 200 RPS per peer, burst 100, tracking TTL 10 minutes | RESOURCE_EXHAUSTED (HTTP 429) |
| circuit-breaker | opens after 5 consecutive failures for 10s, half-open admits 2 probes | UNAVAILABLE (HTTP 503) |
| load-shed | in-flight cap 1,024 (gRPC health checks exempt; so is /grpc.reflection. whenever reflection is enabled) |
RESOURCE_EXHAUSTED (HTTP 429) |
The circuit breaker counts only server-side failures: Unavailable, Internal, and a handler-produced DeadlineExceeded while the request context is still live. Client-caused outcomes — a cancelled or expired request context, and Canceled / InvalidArgument / Unauthenticated / PermissionDenied (which auth and validation return inside the breaker) — are neutral: they neither trip it nor reset the counter. A half-open episode whose probes have not reported back within OpenInterval is abandoned and the next caller opens a fresh one, so a long-lived stream cannot wedge a method shut. For a slow dependency to be visible, keep dependency timeouts below the client deadline.
Measured numbers (benchmarks in the framework README, Apple M1 / go1.26.5): an authenticated unary pass through all eight stages is 80 allocs (ChainUnaryJWT) — that is the figure a capacity plan needs; the auth-skipped health/reflection path is about 1,552 ns and 27 allocs (ChainUnaryAuthSkipped), the difference being HS256 verification, the metadata lookup and JWT parsing. The rate-limiter Allow hot path is 0 allocs (allocs are machine-independent).
One error model
Section titled “One error model”A single mapping table decides how the same error appears on all three protocols: gRPC status, HTTP status, MCP error envelope. The rule is that internal causes never leak — only invalid-argument and unauthenticated forward their messages to the caller; every other category returns a safe message from the registry. When a handler wraps a status with %w, only the innermost status’s own message is forwarded — the wrapper’s context (host, DSN, SQL) and grpc’s rpc error: code = … boilerplate are replaced by the registry’s safe message.
The error-resolution hot path is about 70 ns and 2 allocs (using Go 1.26’s errors.AsType).
Batteries
Section titled “Batteries”| Component | Notes |
|---|---|
| Config | Layered loading, fail-loud, masked secrets. See Configuration |
| Health | Three-state health mapped to gRPC Health; app.Health().Register(...) wires real dependencies |
| Logging/Observability | Structured logs via slog + OTLP export |
| Database | PgBouncer-safe pgx pool + sqlc |
| Cache | Process-local in-memory by default; cache.driver: redis switches to distributed (zero-dependency RESP client) |
| MQ | NATS (core or JetStream); empty mq.group_id = fanout, non-empty = load-balance |