Repository navigation
Router: add the Go HTTP server, structured JSON logging and a healthcheck subcommand (not shipped) #10326
Description
Part of github/gh-aw-firewall#10261
(step 3 of 7).
Prerequisites
containers/router/go/ contains the Go module (one flat main package) from #10312,
merged in #10313: the strict request decoder, table loader, classification/routing service
and CLI, while the image still runs Python. The parity fixtures under
tests/fixtures/parity/ are merged (#10307). Their README.md lists the approved decisions
for the Go port, including the ones this step implements. The fixtures themselves don't cover HTTP or serve: this step tests them by
porting test_http.py and the serve tests in test_cli.py to Go. The differential CI
job keeps comparing the CLI commands only; it doesn't compare HTTP.
Summary
This step adds three things to the existing Go core:
- A
servecommand with the four endpoints and the Python service's safeguards (body
limit, concurrency limit, timeouts), built onnet/http. - Structured JSON logs on stderr.
- A
healthchecksubcommand, so the later Go image can check its health without Python
or a shell.
CI builds and tests all of this, but the image still runs Python.
Background
Today cli.py _serve runs the FastAPI app from http.py under Uvicorn with
limit_concurrency=64 and timeout_keep_alive=5. http.py wraps the app in two layers:
DeadlineMiddleware (30 s, outermost) and BodyLimitMiddleware (1 MiB). It also maps
failures to the error envelope ({"code", "detail"}) as follows:
| Condition | Status | code |
|---|---|---|
| JSON parse failure | 400 | invalid_json |
| Schema violation | 422 | invalid_json |
| Invalid request, or a classification or routing error | 422 | invalid_request |
| No route | 422 | no_route |
| Unknown path | 404 | not_found |
Wrong method (with Allow) |
405 | method_not_allowed |
| Content type isn't JSON | 415 | unsupported_media_type |
| Body over 1,048,576 bytes | 413 | payload_too_large |
| Deadline passed before the response started | 504 | request_timeout |
| Unhandled error | 500 | internal_error |
| More than 64 requests in flight | 503 | none: a bare response, as openapi.yaml documents |
Uvicorn's own logs are plain text, so there are no structured logs today. AWF and the image
both check health by running a Python one-liner against GET /healthz.
Scope
serveand its HTTP handler in Go, built onnet/httpas it is: its parser,ServeMux,
MaxBytesReader, server timeouts andServer.Shutdown.- Structured JSON logging for
serve, written withlog/slog. - A
gh-aw-router healthchecksubcommand. - Tests ported from
test_http.pyand theservetests intest_cli.py.
Specification
Use the standard library's HTTP behavior rather than reproducing Uvicorn's or Starlette's.
The only client is the api-proxy (routing-router-client.js and routing-controller.js).
It sends well-formed POST requests with Content-Type: application/json and a
Content-Length, and acts on only three outcomes: a 2xx success, a 204 from /healthz, and
a 422 with no_route. Any other status of 400 or above is the same contract error to it.
So keep the endpoints, the success bodies, the error envelope for the application-level
errors in the table above, and the 422 no_route. Where net/http differs from Uvicorn on
framing, path cleaning or timeouts, take net/http's behavior and record it in the fixture
README's decisions table. Don't write code to reproduce Uvicorn.
The decisions already in the README that apply here are the trailing-slash 404, stricter bind
ports, invalid log levels as usage errors, JSON serve startup errors, and the 501 for an
unsupported transfer coding. JSON serve logging is explicitly required by the parent issue.
serve options
--bind HOST:PORT.- Env fallback
GH_AW_ROUTER_BIND, default127.0.0.1:8737. - The host is everything before the last colon. The port must be ASCII digits from 1
to 65535. - Errors exit 2 with a structured ERROR record and one of the fixed reasons
bind must use HOST:PORT syntaxor
bind must contain a host and port from 1 through 65535.
- Env fallback
--log-level.- Env fallback
GH_AW_ROUTER_LOG_LEVEL, defaultinfo. An empty bind or log-level env
var counts as unset, asGH_AW_ROUTER_ROUTING_TABLESalready does (a decision). - Accepts
critical,error,warning,info,debugandtrace. An invalid value
from either the flag or the env var is a usage error (exit 2). - Map
traceto debug, andcriticalto error and above.
- Env fallback
- Startup order, matching
run().- Parse the arguments.
- Load and validate the tables. On failure, exit 2.
- Parse the bind address.
- Listen. If listening fails (address in use, permission denied), log an error and
exit 1, as Uvicorn does.
Server
Use one http.Server with:
ReadHeaderTimeoutof 10 s andReadTimeoutof 30 s, so a slow upload can't hold a
connection past the 30 s deadline Python enforces;WriteTimeoutof 30 s andIdleTimeoutof 5 s, which match Uvicorn's keep-alive;- the default header size limit, and no HTTP/2.
Leave framing to net/http's HTTP/1.1 parser. It rejects a bad, signed or conflicting
Content-Length and a missing Host with a bare 400 before any handler runs, and answers an
unsupported transfer coding with a 501 (an existing decision). It also rejects whitespace
before a header colon with a 400, and closes the connection without a response on an invalid
chunk. Don't add checks for the cases where it is more lenient than Uvicorn or differs from
it, such as OPTIONS *, an absolute-form target or an invalid chunk; record them as
decisions instead.
Request pipeline
- Concurrency. Allow 64 requests in flight across all routes,
/healthzincluded, with
a buffered channel used as a semaphore. Answer the 65th with a bare 503 that has no error
envelope. - Body limit. If the declared
Content-Lengthis over 1,048,576 bytes, return 413
without reading the body. Otherwise wrap the body inhttp.MaxBytesReader, and return
413 when reading fails with*http.MaxBytesError, which covers chunked uploads. The
detail isrequest body exceeds the 1048576-byte limit. Only the planning endpoints read
the body, so an oversized chunked upload to another path gets that path's 405 or 404,
where Python buffers POST, PUT and PATCH bodies first and answers 413 (a decision). - Routing. Register the four paths on an
http.ServeMuxwithout method patterns, plus a
catch-all/that returns 404not_found.- Each handler checks the method itself and returns 405
method_not_allowedwith an
Allowheader, for HEAD and OPTIONS too. Method patterns would answer a wrong method
with a plain-text 405 and allow HEAD implicitly. - Take the rest from
ServeMux: percent-encoded paths are decoded (/%68ealthzis
/healthz), the query string is ignored,/healthz/is 404, and//healthzand
/a/../healthzget a 307 redirect to the cleaned path.
- Each handler checks the method itself and returns 405
- Endpoints.
GET /healthzreturns 204 with no body.GET /capabilitiesreturns 200 with the same JSON as Python's for the same tables.POST /classifyandPOST /route:- Check the media type with
mime.ParseMediaType. A missing type,
application/json, and anyapplication/*+json, with or without parameters, are
accepted; anything else returns 415. - Decode the request, which can return 400 or 422.
- Run the service, which returns 200 or 422.
- Check the media type with
- Panics. Recover them, return 500
internal_error, and log at error level with a stack
trace. Never log the request body.
There's no separate deadline middleware. Planning is CPU-bound and takes milliseconds on a
body of at most 1 MiB, the server timeouts bound slow clients, and the api-proxy has its own
timeout. So the 504 request_timeout response goes away: a request that passes a server
timeout has its connection closed. Record this as a decision, and remove the 504 from
openapi.yaml at the cut-over, when the Go server becomes the one that ships.
Responses
- JSON. Use
Content-Type: application/json.encoding/json/v2doesn't escape<,>
and&by default, which matters because the classifier prompt is large and XML-escaped
and the api-proxy limits router responses to 1 MiB. - Optional fields. Leave absent optional fields out, as Python's
exclude_nonedoes. - Error details.
- A schema violation's detail is
request does not match the contract:followed by the
Go decoder's message. Details aren't compared, so this needs no decision. - Replace characters that
unicode.IsPrintrejects with spaces, then truncate to 500
code points. - Don't echo caller-supplied values.
- A schema violation's detail is
Graceful shutdown
On SIGTERM or SIGINT:
- Log the shutdown and stop accepting connections.
- Let in-flight requests finish for up to 0.5 s. Router requests are CPU-bound and take
milliseconds, so this is plenty. - Close whatever is left, log a summary, and exit 0.
The process must exit within 1 s of the signal.
- AWF's compose service sets
stop_grace_period: 2s, but AWF's teardown
(runComposeDowninsrc/container-stop.ts) runsdocker compose down -v -t 1. That
overrides the grace period, so the router gets SIGKILL 1 s after SIGTERM. - The subsequent Go container tests require exit code 0 after
docker stop. - Uvicorn today waits for in-flight requests with no limit, and AWF kills it after 1 s.
healthcheck subcommand
-
gh-aw-router healthcheck [--url URL]sendsGETtohttp://127.0.0.1:8737/healthzby
default. -
It exits 0 only on a 204. Anything else exits 1: another status, refused, timed out.
-
It writes nothing to stdout, and at most one short line to stderr.
-
It uses a 1 s timeout, which matches today's image check, and never uses a proxy.
-
It doesn't load routing tables. The check runs every few seconds, so it must not
depend on the tables.This differs from
run(), which loads tables before every command. Document the
difference.
Structured logging (serve only)
-
Format. One JSON object per line on stderr, written with
log/slog's JSON handler.
Every record has:time: RFC 3339, UTC, millisecond precision;level,msg;service:gh-aw-router;version.
-
Events. "(no body)" below means the event has no extra fields.
Event ( msg)Level Extra fields startingINFO bind,log_level,go_versionconfiguration invalidERROR error_code: invalid_request,error(a fixed, sanitized reason). The process then exits 2.routing tables loadedINFO tables_path,profiles,models,label_cellsrouting tables invalidERROR error_code: invalid_model_data,error(the loader message). The process then exits 2.listeningINFO addresslisten failedERROR error. The process then exits 1.requestINFO below 500, ERROR for 500 and above request_id,method,route,status,duration_ms,error_code(error envelopes only),request_bytes,response_bytespanicERROR request_id,route,stackshutting downINFO signalstoppedINFO drained(whether in-flight requests finished before the drain limit),duration_ms -
servestderr. After the arguments are parsed,servewrites only JSON records:
table-load failures and bind validation errors are therouting tables invalidand
configuration invalidrecords above, with exit 2. Usage errors are still plain usage
text starting withusage: gh-aw-router, as in the other commands: an unknown option, a
missing value, or an invalid log level from the flag or the env var.--helpand the
other CLI commands stay human-readable. -
Normalize
methodto a fixed list of standard HTTP methods or_OTHER. Loader and
listenererrorfields must be sanitized operational reasons, not raw error objects.
Stack traces must omit panic values and request data. -
routeis the matched path (/healthz,/capabilities,/classifyor/route),
orunmatched. Never log the raw path or the query string. -
request_idis a random 128-bit hex value generated for each request. It isn't
taken from request headers and isn't returned to clients, so the API doesn't change.
The subsequent router OpenTelemetry step addstrace_idandspan_id. -
/healthz. Don't log successful health checks. Do log any other/healthz
response. -
Privacy. Every field comes from the allowlist in the table above. Never log any of
the following:- request or response bodies, prompts, conversation text or tool inputs;
- model ids or choice ids from requests;
- headers, including
AuthorizationandUser-Agent; - query strings, or raw paths for unmatched routes;
- 4xx
detailstrings. Log onlyerror_code, because a detail can name a key the
caller supplied.
-
Other commands. The CLI commands other than
servedon't log. Their stdout and
plain one-line stderr stay unchanged.
Tests
Port test_http.py and the serve tests in test_cli.py to Go, so that the Go tests cover
everything the Python tests cover before the cut-over removes them. In addition:
- Handler tests (
httptest).- Every row of the error table, comparing the status and
code;detailis compared only
where a test checks sanitization. - The method and path matrix: unknown paths, trailing slash (404, an approved decision),
doubled slash, dot segments, percent-encoding (/%68ealthzis/healthz,/%2Fhealthz
is 404), query strings, and wrong methods including HEAD and OPTIONS, with theirAllow
headers. - The content-type matrix: none,
application/json; charset=utf-8and
application/vnd.example+jsonare accepted, andtext/plaingets 415. - Success responses from each endpoint, compared as JSON, with absent optional fields
left out. - Body limit:
- a declared
Content-Lengthof 1,048,577 onGET /healthzand on a POST to an
unknown path; - a chunked body one byte over the limit;
- a body exactly at the limit.
- a declared
- Every row of the error table, comparing the status and
- Concurrency. Through a test hook that holds handlers open, 64 concurrent requests
succeed and the 65th gets a bare 503. - Server settings. The
http.Serverhas the timeouts and limits above; a unit test
checks the configured values rather than waiting for them to expire. - Panics. A forced panic returns 500, and a
panicrecord is logged. - Sanitization.
- A detail with control characters gets spaces in their place.
- A detail over 500 code points is truncated.
- Distinctive "canary" values placed in the body, the headers and the query never
appear in any response detail or log line.
- Logs.
- Parse every stderr line as JSON.
- Check the required fields and the field allowlist.
- Check that successful
/healthzcalls produce norequestrecord.
- Shutdown (Linux and macOS only).
- Start the binary and begin a request that a test hook holds for about 0.2 s, which is
less than the drain budget. - Send SIGTERM.
- Assert that the request completes, the process exits 0 within 1 s, and the
shutting downandstoppedrecords appear. - Repeat with a request held longer than 0.5 s. The connection closes, and the process
still exits 0 within 1 s.
- Start the binary and begin a request that a test hook holds for about 0.2 s, which is
healthcheck. Covers healthy, a 500, a refused connection and a timeout. Also check
that it succeeds whenGH_AW_ROUTER_ROUTING_TABLESpoints to a missing path.serveoption tests. The CLI entry point needs a test hook that stops just before
listening and reports the resolved host, port and log level. Cover--bindand
--log-levelfrom options and fromGH_AW_ROUTER_BINDandGH_AW_ROUTER_LOG_LEVEL, the
defaults, the bind syntax and port-range errors, non-digit ports such as+80, and
invalid log levels from the flag and the env var. For startup failures, every stderr line
must parse as JSON, and one ERROR record must carry the expectederror_code.- A new difference from Python that the api-proxy can observe (a status it acts on, a
success body, theno_routeenvelope) goes through a reviewed change to the fixture
README's decisions. Differences in transport-level behavior thatnet/httpowns are
recorded there once, in this step's PR, without per-case tests.
Out of scope
- The Dockerfile and image changes. The subsequent cut-over switches
HEALTHCHECKto the
healthchecksubcommand. - OpenTelemetry, which is a subsequent step.
- Changes to the API, the headers or the error codes, apart from dropping the 504, which the
cut-over removes fromopenapi.yaml. - Reproducing Uvicorn's HTTP/1.1 parsing, Starlette's path handling, or its timeout
responses.
Acceptance criteria
-
serveimplements the four endpoints and pipeline onnet/httpwithout custom
framing, path or timeout code. The handler andserveoption tests above pass on
Linux and Windows, andtest_http.pyand theservetests intest_cli.pyare
ported. - The body limit (declared and chunked), the 64-request limit, panic recovery and error
sanitization have tests, and the server's timeouts are set as specified. - This step's PR updates the fixture README's decisions table with the
net/http
behavior it takes: the redirect for doubled slashes and dot segments, server timeouts
instead of the 504,OPTIONS *, absolute-form targets, invalid chunked bodies,
oversized chunked bodies on paths that don't read them, and empty serve env vars.
(Whitespace before a header colon gets a 400, as in Uvicorn, so it needs no row.) - Startup validation is tested: invalid tables exit 2 with an ERROR log record, an
invalid bind or log level exits 2, and a listen failure exits 1. - SIGTERM drains requests and exits 0 within 1 s, and this is tested.
-
gh-aw-router healthcheckworks without loading tables, and its behavior is tested. - Logs are JSON with the documented fields only. Successful
/healthzcalls aren't
logged, and the canary test shows that no request content reaches the logs. - The router README documents
serve,healthcheck, the log format and fields, and
the env vars.
Files likely touched
containers/router/go/: new files in the same flatmainpackage, such asserver.go
andlogging.gowith tests beside them, andcli.goforserveandhealthcheck(names
are suggestions)containers/router/README.mdcontainers/router/tests/fixtures/parity/README.md(decisions)
Activity