Clearing API Surface
How Ductor serves its API — tri-protocol serving, transport planes, the interceptor chain, versioning, rate limiting, pagination, and health endpoints.
Ductor's API is defined once and served three ways. This page is the map of the transport surface: how a request reaches a handler, what runs on the way in, and where to find things. For the human-browsable method list, see the API reference; for error shapes, see Errors & status codes.
Tri-protocol serving
Every method is declared once in protobuf under api/proto. From that single
definition, each method is served as:
- ConnectRPC and gRPC — the binary/streaming RPC surface.
- RESTful HTTP/JSON — generated from
google.api.httpannotations on the RPC.
One RPC definition (plus its google.api.http annotation) in
api/proto/api/*.proto fans out to:
- ConnectRPC handler —
POST /ductor.api.<Service>/<Method> - gRPC handler — the same service/method over HTTP/2
- REST/JSON route — e.g.
GET /api/v2/…,POST /api/v2/…
REST paths live largely under /api/v2, with a number of legacy /api paths
still served for compatibility. Because JSON routes are generated from the proto
annotations, the REST surface never drifts from the RPC surface.
OpenAPI is generated and CI-gated
The OpenAPI spec and API docs (api/openapi/openapi.yaml,
transport/gateway/openapi/openapi.yaml, docs/api/*) are committed
artifacts regenerated from the proto. To change them, edit the proto
comments/annotations and run make openapi — never hand-edit the generated
files. make openapi-check fails CI if the committed artifacts are stale.
Transport planes
The listener surface is split into two planes, each with its own interceptor chain, method registry filter, and listen address, sharing one handler set:
| Plane | Audience | Auth | Rate limited | internal_only methods |
|---|---|---|---|---|
| External | Public clients | OIDC + API key | yes | filtered out — never registered |
| Internal | Service-to-service | Service token (HS256) or mTLS | no | admitted (only place they're reachable) |
Methods marked (authz).internal_only = true in the proto are filtered out of
the external plane's registry, so the public listener cannot serve them at all.
Internal plane defaults to loopback
The internal plane refuses to start when bound to a non-loopback interface
unless mTLS is required. The default internal address is non-loopback, so
operators must either bind it to 127.0.0.1 / [::1] or set
server.internal_require_mtls=true. This is a fail-closed guard against
accidentally exposing internal RPCs.
Interceptor chain
Both the Connect and gRPC chains run the same interceptors in the same order, outermost (first on the way in) to innermost (closest to the handler):
Recovery panic recovery → log stack → return Internal
Telemetry RPC metrics
Request-ID inject request_id (from header or generated UUID)
API-Version negotiate Api-Version, SDK check, deprecation headers
AuthN token validation, Principal injection
AuthZ policy evaluation against the method registry
Tenant extract tenant from Principal claims into context
Deprecated-RPC observational warn/counter for deprecated methods
ProtoValidation validate request via buf/protovalidate annotations
Concurrency global + per-tenant in-flight caps (if configured)
RateLimit per-method token bucket (if a limiter is wired)
ErrorMasking mask Internal/Unknown before they leave
Status map domain errors → Connect/gRPC codes
SlowRequest log full request durationThe ErrorMasking → Status ordering matters: on the return path, Status converts
domain errors to proper codes first, then ErrorMasking sees only coded errors and
hides Internal/Unknown details. Concurrency and RateLimit are only added when
configured — an unconfigured limiter is simply absent from the chain.
Discovery endpoints
Ductor is self-describing. These endpoints (all behind the standard tenant-auth + authz chain) let clients introspect what the server offers:
| Endpoint | Returns |
|---|---|
GET /api/_meta/services | The service inventory — which services this build serves |
GET /api/capabilities | The pre-computed capabilities report |
GET /api/v2/capabilities, /api/v2/capabilities/{id} | The proto-served capabilities surface |
GET /api/config/schema | Structural metadata for the dynamic-config surface |
GET /api/config/export | The writable dynamic-config values for the caller's tenant |
/api/config/export returns 503 when the dynamic-config module is off (its
default) — a deliberate "fail visibly" rather than pretending an empty surface.
API versioning
Version is negotiated with a date-string header:
- Clients send
Api-Version: 2024-01-01. If omitted, the server assumes the current version. - Current = Min =
2024-01-01. A requested version older than the minimum is rejected with400/INVALID_ARGUMENT. - The negotiated version is echoed back in the
Api-Versionresponse header.
Deprecation signaling
When a called method is deprecated, the response carries standard signal headers:
| Header | Value |
|---|---|
Deprecation | the literal true, always emitted for a deprecated method |
Sunset | the removal date — only when a sunset date is configured |
Link | <successor>; rel="successor-version" — only when a successor is set |
The sunset date is operator-configured, so the planner never bakes a removal date into the build.
Rate limiting
Rate limiting is a per-method token bucket. Each method has a token cost
(TokenCosts); cheaper methods drain fewer tokens. When a caller is throttled the
response is 429 (RATE_LIMITED) with:
X-RateLimit-LimitX-RateLimit-RemainingRetry-After(seconds)
Rate limiting is optional
The limiter is only installed when one is wired (it is Redis-backed). When no limiter is present — e.g. Redis is absent — the interceptor is a pass-through and no throttling occurs. Don't rely on rate limiting being on in a deployment without Redis.
Pagination
There are three pagination conventions on the surface — don't conflate them:
| Convention | Shape | Where |
|---|---|---|
Offset/cursor PaginationMeta | { next_cursor, total, limit, offset, has_more } | REST list endpoints (domain/shared) |
Proto next_page_token | opaque next_page_token string | proto-defined list RPCs |
Marketplace APIMeta | { total, page, per_page } | the marketplace REST API |
For the offset/cursor convention, limit defaults to 50 and is clamped to a
maximum of 1000; a non-positive limit falls back to the default and a negative
offset resets to 0.
Health & ops endpoints
These are unauthenticated and mounted on the gateway directly:
| Endpoint | Kind | Behavior |
|---|---|---|
/health | Liveness | Static {"status":"ok"} — the process is up |
/ready | Readiness | Boot flag plus live Postgres/Redis/queue checks, bounded to 1s; 503 when not ready |
/metrics | Metrics | Prometheus exposition |
/openapi.yaml | Spec | The committed OpenAPI document |
/docs, /docs/ | Docs UI | Scalar-rendered interactive API reference |
Liveness never touches dependencies (so a slow database can't flap pods);
readiness runs the same live dependency checks the Connect Ready RPC uses, but
only a hard-unhealthy aggregate fails the probe — a degraded component keeps the
instance in rotation. These paths also have registered -z aliases — see
Health checks.
Gotchas
Authz fails OPEN by default
When no authorizer is configured, authorization fails open (allow) to
preserve backward-compatible behavior. This is deliberate at the wiring seam,
but it means an unconfigured deployment is not enforcing policy. Set
authz.allow_when_unconfigured=false (which maps to the chain's
deny-when-unconfigured posture) to fail closed. See
Configuration.
- Internal errors are masked.
Internal/Unknownerrors are replaced on the wire withInternal error (ref: <8-hex>), and the real error is logged server-side under that ref. See Errors & status codes. - Never edit generated OpenAPI/proto stubs. Edit the proto, run
make openapi, and commit the regenerated artifacts.