Reference

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.http annotations on the RPC.
Proto definition ConnectRPC handler gRPC handler REST / JSON route

One RPC definition (plus its google.api.http annotation) in api/proto/api/*.proto fans out to:

  • ConnectRPC handlerPOST /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:

PlaneAudienceAuthRate limitedinternal_only methods
ExternalPublic clientsOIDC + API keyyesfiltered out — never registered
InternalService-to-serviceService token (HS256) or mTLSnoadmitted (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 duration

The 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:

EndpointReturns
GET /api/_meta/servicesThe service inventory — which services this build serves
GET /api/capabilitiesThe pre-computed capabilities report
GET /api/v2/capabilities, /api/v2/capabilities/{id}The proto-served capabilities surface
GET /api/config/schemaStructural metadata for the dynamic-config surface
GET /api/config/exportThe 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 with 400 / INVALID_ARGUMENT.
  • The negotiated version is echoed back in the Api-Version response header.

Deprecation signaling

When a called method is deprecated, the response carries standard signal headers:

HeaderValue
Deprecationthe literal true, always emitted for a deprecated method
Sunsetthe 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-Limit
  • X-RateLimit-Remaining
  • Retry-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:

ConventionShapeWhere
Offset/cursor PaginationMeta{ next_cursor, total, limit, offset, has_more }REST list endpoints (domain/shared)
Proto next_page_tokenopaque next_page_token stringproto-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:

EndpointKindBehavior
/healthLivenessStatic {"status":"ok"} — the process is up
/readyReadinessBoot flag plus live Postgres/Redis/queue checks, bounded to 1s; 503 when not ready
/metricsMetricsPrometheus exposition
/openapi.yamlSpecThe committed OpenAPI document
/docs, /docs/Docs UIScalar-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/Unknown errors are replaced on the wire with Internal 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.