Tests were run against a private reference implementation: gate ok after every task in order, smoke ok (stream spread ~1000 ms). The reference is not in the repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.7 KiB
v0 task 03: the routing reverse proxy
Branch: v0 (run git switch v0; git status --short must be empty, otherwise stop)
Commit subject: Add the routing reverse proxy
Goal
Write internal/proxy: an http.Handler that takes /{route}/v1/…, picks a host from the
route's list using the health table, forwards the request with httputil.ReverseProxy, streams
the answer back as it arrives, and tells the health table when a host fails.
Context
Clients (OpenCode, Hermes) only know a base URL, so the route is the first path segment:
http://crossbar:7777/opencode-a/v1/chat/completions. The upstream must see /v1/chat/completions
with the query string kept. Answers are often server-sent-event streams of hundreds of small
chunks over minutes; a proxy that buffers them makes the client look frozen, so FlushInterval
is -1 (flush after every write) and anything wrapping the ResponseWriter must still
implement http.Flusher. Request bodies are looked at once, for a top-level "model" field, so
that a request for a model only one host has loaded goes there; the body is then handed to the
upstream unchanged. Bodies are never logged.
Files
- Copy:
internal/proxy/proxy_test.go - Create:
internal/proxy/proxy.go - Modify:
docs/implementer-log.md
Interfaces
Produces, in internal/proxy/proxy.go, package proxy:
const MaxBody = 16 << 20 // largest request body we look at
const HostHeader = "X-Crossbar-Host" // set on every proxied response: the host that answered
// Health is what the proxy needs from the health table (internal/health satisfies it).
type Health interface {
Get(name string) (health.Status, bool)
MarkDown(name, reason string)
}
type Handler struct { /* private: *config.Config, Health, *slog.Logger */ }
func New(cfg *config.Config, h Health, log *slog.Logger) *Handler // nil log -> slog.Default()
func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// SplitRoute takes the first path segment as the route.
// "/a/v1/x" -> ("a", "/v1/x", true) "/a" and "/a/" -> ("a", "/", true)
// "/", "//x", "", "noslash/v1" -> ("", "", false)
func SplitRoute(path string) (route, rest string, ok bool)
// Choose: the first host in order that is healthy and lists model in Loaded; failing that, the
// first healthy host; ok == false if none. model may be "".
func Choose(hosts []string, model string, h Health) (string, bool)
Rules the tests check, in the order ServeHTTP applies them. Every error answer is JSON
{"error":"<msg>"} with Content-Type: application/json:
SplitRoute(r.URL.Path)not ok → 400missing route.- Route not in
cfg.Routes→ 404unknown route. restmust start with/v1/or be exactly/healthor/props; else → 404not found. (/_crossbar/…through a route is therefore 404 too.)- Model peek. For requests other than GET/HEAD with a body: read up to
MaxBody + 1bytes (io.LimitReader); more thanMaxBody→ 413body too large. Put the bytes back (r.Body = io.NopCloser(bytes.NewReader(body)),r.ContentLength = len(body)). Then try to decode{"model": "…"}; a body that is not JSON, or has no model, simply gives""— that is not an error. If the model is"", use the route'sDefaultModel. Choose(route.Hosts, model, h)not ok → 503no healthy host. Nothing is marked down by rules 1–5.- Forward with a
httputil.ReverseProxy:Rewrite:pr.SetURL(target)wheretargetis the host'sBaseURLparsed once;pr.Out.URL.Path = target.Path + rest;pr.Out.URL.RawPath = "";pr.Out.Host = target.Host;pr.SetXForwarded(). The query string is kept (the tests check/v1/models?x=1arrives as/v1/models?x=1).FlushInterval: -1.ModifyResponse: setHostHeaderto the host's name.ErrorHandler: iferrors.Is(err, context.Canceled), do nothing (the client left); otherwiseh.MarkDown(name, err.Error())and answer 502{"error":"upstream failed","host":"<name>"}.
- One log line per proxied request, after it finishes, through the logger:
p.log.Info("request", "route", …, "host", …, "method", …, "path", rest, "status", …, "ms", …). To know the status, wrap theResponseWriterin a small recorder that implementsWriteHeaderandFlush(forwarding to the underlyinghttp.Flusher). WithoutFlushthe reverse proxy cannot stream andTestStreamingIsNotBufferedfails. - Never log, print or keep a request or response body. Never panic on a request.
Steps
- 1. Copy.
git switch v0
cp docs/plans/v0/files/internal/proxy/proxy_test.go internal/proxy/
Read the test. fakeHealth stands in for the table; newUpstream records what arrived.
TestStreamingIsNotBuffered is rule 6/7: the upstream sends one chunk and then waits until the
test has read it; a buffering proxy hangs there.
- 2. See the test fail.
go test ./internal/proxy/. Expected: it does not compile. - 3. Write
internal/proxy/proxy.go.gofmt -w internal/proxy/. - 4. See the test pass.
go test -race -count=1 ./internal/proxy/. Expected:ok. - 5. Run the gate.
make gate. Expected last line:gate: ok. - 6. Log and commit. Row
v0/03-proxy.
git add internal/proxy docs/implementer-log.md
git commit
Done when
go test -race -count=1 ./internal/proxy/isok;make gateprintsgate: ok.cmp internal/proxy/proxy_test.go docs/plans/v0/files/internal/proxy/proxy_test.goprints nothing.
Stop and report if
TestStreamingIsNotBufferedstill fails withFlushInterval: -1and a recorder that implementsFlush: stop and describe exactly what you wrote.