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:
| Version | Status | Endpoints |
|---|---|---|
v1 | Removed | All paths return HTTP 410 Gone |
v2 | Preferred | /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 ofversion → IMF-fixdatefor 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.RemoteAddrwith the port stripped.X-Forwarded-Foris NOT consulted unless--trust-proxy-headers=trueis 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 ceilingmax(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:
| Sentinel | HTTP | Body code | Cause |
|---|---|---|---|
ErrToolUnmarshal | 400 | bad_args | JSON body did not decode into the typed Args struct |
ErrToolArgInvalid | 400 | invalid_args | Argument value out-of-range, unknown enum, missing required field |
ErrTenantScopedRefused | 403 | tenant_scoped_bearer_refused | Tool refuses tenant-scoped bearers (run_analysis) or bearer's ACL does not include the requested cluster_id |
ErrSQLNotReadOnly | 400 | sql_not_read_only | sqlguard rejected the SQL (write verb, filesystem reader, URL scheme, stacked statements) |
ErrToolBackend | 500 | tool_backend_failure | DuckDB 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:
/api/versions.deprecatedlists"v2"andremoval_schedulecarries the planned removal date.- v2 responses carry
Sunset: <IMF-fixdate>,Link: </docs/api-versioning.md>; rel="deprecation", andIngero-API-Removal: v3.0.0headers. - 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.