Echo HTTP API versioning

May 17, 2026 · View on GitHub

Echo's HTTP+JSON surface is URL-versioned. /api/versions is the unversioned capability-negotiation endpoint; everything else lives under /api/vN/.

Supported versions

As of v1.0.0:

VersionStatusEndpoints
v1RemovedAll paths return HTTP 410 Gone
v2Preferred/api/v2/tools/<name>, /api/v2/tools/list, /api/v2/openapi.json, /api/v2/sql, /api/v2/health, /api/v2/whoami

Future versions follow the SemVer policy: a MAJOR bump (v2.0.0) introduces a new path prefix; MINOR + PATCH additions stay on the existing prefix.

/api/versions contract

The response shape is byte-stable from v0.18 onward (key set stable):

{
  "supported": ["v2"],
  "deprecated": [],
  "preferred": "v2",
  "binary": {"component": "echo", "version": "v1.0"},
  "capabilities": {...},
  "removal_schedule": {}
}
  • supported: array of live version strings. v1.0 ships ["v2"].
  • deprecated: array of versions that still respond but will be removed; empty in v1.0 since v1 is already gone.
  • preferred: the version new clients should target.
  • binary: component name + MAJOR.MINOR. The full patch version is gated behind bearer at /api/v2/health (R3 ★4-G: patch disclosure on unauth path makes CVE-targeted scanning trivial).
  • capabilities: per-query-type flags the plugin reads to enable / disable corresponding UI features.
  • removal_schedule: map of version → IMF-fixdate for versions scheduled to be removed in the future. Empty in v1.0.

The key set will not change without a MAJOR bump.

Rate limiting on /api/versions

/api/versions is unauthenticated, so the per-bearer rate-limit bucket cannot key off identity. Echo applies a dedicated per-source-IP token bucket:

  • Default: 30 rps / 100 burst per source IP.
  • Bucket cap: 4096 distinct source IPs tracked. Beyond that, the LRU entry is evicted.
  • Source IP: r.RemoteAddr with the port stripped. X-Forwarded-For is NOT consulted unless --trust-proxy-headers=true is set.

The 4096-cap LRU prevents an IP-rotation DoS from growing the bucket map unboundedly (especially relevant over IPv6).

v1 → v2 cutover

Shipped clean in v1.0.0: every /api/v1/* path returns HTTP 410 Gone with the stable JSON body and a Sunset header (RFC 8594):

HTTP/1.1 410 Gone
Sunset: Fri, 15 May 2026 14:06:16 GMT
Link: </docs/api-versioning.md>; rel="deprecation"
Content-Type: application/json

Body:

{
  "error": "api/v1 removed in v1.0; use /api/v2",
  "code": "gone",
  "removed_version": "v1",
  "preferred": "v2",
  "docs": "https://github.com/ingero-io/ingero-fleet/blob/main/docs/api-versioning.md"
}

Scanners and operator tooling distinguish 410 from 404: 410 = "intentionally removed; do not retry"; 404 = "maybe wrong path; consider retrying after config check."

OpenAPI spec discovery

/api/v2/openapi.json is the canonical spec. Tenant-scoped bearers see a filtered spec (the run_analysis + /api/v2/sql paths are dropped because dispatch refuses tenant scopes on those routes).

Spec-cache flags

  • --openapi-cache-max-bytes: hard cap on the rendered OpenAPI document (default 1 MiB). Bumps above the hard ceiling max(4 MiB, 8*v0.18-baseline) panic at start to prevent operator misconfiguration from turning the binary into an amplification reflector.
  • --openapi-tenant-cache-entries: per-tenant filtered-spec LRU capacity (default 256, hard cap 4096). The cache key is (BearerHash, AcceptSetEpoch); SIGHUP-driven bearer rotations bump the epoch counter, so all pre-rotation entries become natural cache misses without an eviction loop.

Namespace disparity

The two flags use different units (bytes for the spec body, entries for the LRU). This is intentional: one caps a single buffer in bytes (one cached spec doc); the other caps an LRU map in entries (each entry is a per-tenant spec, sized by the first flag). Different units because different things; do not unify.

Error classification

Cluster-tool dispatch surfaces structured errors with a stable code on the body:

SentinelHTTPBody codeCause
ErrToolUnmarshal400bad_argsJSON body did not decode into the typed Args struct
ErrToolArgInvalid400invalid_argsArgument value out-of-range, unknown enum, missing required field
ErrTenantScopedRefused403tenant_scoped_bearer_refusedTool refuses tenant-scoped bearers (run_analysis) or bearer's ACL does not include the requested cluster_id
ErrSQLNotReadOnly400sql_not_read_onlysqlguard rejected the SQL (write verb, filesystem reader, URL scheme, stacked statements)
ErrToolBackend500tool_backend_failureDuckDB QueryRange / Analysis path failed. Body is scrubbed; full err.Error() is in the audit log keyed by req_id

The body shape on 4xx/5xx is:

{"error":"...","code":"...","tool":"...","req_id":"..."}

req_id is the 16-hex correlation token; it matches the X-Request-Id response header.

v2 response envelope

Every tool's typed Out struct is returned as-is under the result key:

$ curl -s -X POST -H 'Authorization: Bearer ...' \
    -d '{"cluster_id":"c1"}' \
    https://echo.example.com/api/v2/tools/fleet.cluster.find_stragglers
{
  "result": {
    "stragglers": [
      {"node_id": "n1", "count": 7},
      ...
    ]
  }
}

The legacy /api/v1 flat-slice shape ("result": [...]) is gone. Clients that need the flat slice extract it via .result.stragglers (or whichever envelope field name the tool uses); the OpenAPI doc names every envelope field.

Deprecation policy (post-v1.0)

If a future MAJOR ever ships a v3, the deprecation flow for v2 will be:

  1. /api/versions.deprecated lists "v2" and removal_schedule carries the planned removal date.
  2. v2 responses carry Sunset: <IMF-fixdate>, Link: </docs/api-versioning.md>; rel="deprecation", and Ingero-API-Removal: v3.0.0 headers.
  3. Operators get at least one MINOR release window (typically ~3 months) to migrate before v2 paths flip to HTTP 410 Gone.

Per the SemVer policy, Fleet 1.x is a stable line. A v3 API surface is out of scope without an explicit project-owner decision to start fleet-2.x.