request improvements and documentation - #5
Merged
Merged
Conversation
vicent-dev
marked this pull request as ready for review
October 4, 2026 10:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix four silent proxy defects, add
/health, and document the gatewayFour bugs in the proxy path were making the gateway quietly wrong, and none of
them was visible from the client side: an upstream
404/500arrived as200, request headers and query strings never reached the service behind thegateway, and a client disconnect left the upstream call running. This fixes all
four, adds an unauthenticated liveness endpoint, and replaces the README with
documentation that matches what the code actually does.
11 files changed, +1327 / -48. One commit,
65cea94.The four proxy fixes
1. Every proxied response reached the client as
200 OKdefaultRouteHandlercopied the upstream headers and body but never calledw.WriteHeader, so the first body write implied200. An upstream404or500was flattened into a success.app/route.gowritesw.WriteHeader(call.Response.StatusCode)explicitly, onboth the live and the cached path. A cache hit restores the status it stored,
so the two paths are identical.
Call.SetValuenow rejects a decoded status outside100–999(
pkg/request/call.go). The handler hands that value straight toResponseWriter.WriteHeader, which panics there — a payload that decodes butcarries an impossible code is corrupt in exactly the same way an undecodable
one is, so it fails the read instead of becoming a panic per request until the
entry expires.
2. Request headers never reached the upstream
Client.Requestbuilt the upstream request from the method, URL and body only.Authorization,Content-Type, cookies and custom headers were all dropped.pkg/request/client.gonow clones the caller's header map, strips the hop-by-hopset, strips the gateway's own bearer, and forwards the rest. The map is cloned
rather than shared because
NewCallrewrites the inbound request in place — analiased map would let the outbound request keep rewriting the inbound one.
Authorizationis stripped deliberately. The bearer authenticates the calleragainst the gateway; handing the same token to every upstream would spread a
credential that is valid here, and a compromised upstream could replay it.
Consequence to be aware of: nothing replaces it, so a backend still cannot tell
which caller it is serving. That stays a known limitation rather than becoming a
guess at a header contract.
app.copyUpstreamHeadersapplies the same rule on the way back, minusContent-Length, since the body is buffered here and its length is thishandler's to declare.
3. Query strings were dropped
Call.requestUrlwas built fromr.URL.Path, which never carries the query. Theworse half: the fingerprint hashed the whole
*url.URL, so?page=2got its owncache entry for a byte-identical upstream request.
The query is now appended after the rewrite, and only when non-empty, so no
request grows a bare
?. It is appended after matching, never before, so aquery cannot change which service answers.
4. A client disconnect did not cancel the upstream call
The request was built with
http.NewRequest, so nothing tied the upstream callto the client that asked for it.
http.NewRequestWithContextis used now. The asynchronous cache write is thedeliberate exception: it runs with
context.WithoutCancel(ctx), so a caller whohangs up after the response was fetched whole does not throw it away. That
exception is the reason this fix needed two tests rather than one.
New:
GET /healthapp/health.goanswers200 {"status":"ok"}with no token, registered on theroot router ahead of the authenticated catch-all — which is what makes it
reachable by a probe that holds no credentials. It never reaches
pkg/request,so it answers even with no services configured at all.
It is liveness only: it checks no dependency, so a gateway with every
upstream down still reports healthy. There is no readiness endpoint, and this is
a deliberate scope decision rather than an oversight — the one dependency the
gateway cannot start without is already required to boot, and the cache is built
to fail open, so a probe that checked them could only repeat what start-up
proved.
Also added: a compose
healthcheckfor theappservice, speaking HTTP overbash's
/dev/tcpso the image needs no extra package.Bug found while writing the tests: the rate limiter does nothing
Not part of the brief, so not fixed here — but it invalidates a claim the
docs used to make, and it would mislead the next reader.
rateLimiterMiddlewarebuildsrate.NewLimiter(5, 10)inside the middlewareconstructor, and gorilla/mux calls that constructor again on every matched
request (
Router.Matchrebuilds the chain per request). Every request thereforestarts from a fresh, full bucket,
Allow()always succeeds, and no request hasever been refused with
429.TestRateLimiterMiddleware_Throttlepasses only because it calls the builthandler directly instead of going through the router, so it was green while the
server throttled nothing. That test now carries a comment saying so.
Fixing it means hoisting the limiter to server state, and then making it
per-client — one global 5 rps bucket would throttle almost all real traffic.
Tracked in
AGENTS.md.Documentation
README.md— rewritten end to end (49 → ~650 lines): what the gatewaydoes, architecture and request-flow diagrams, auth, routing, response cache,
configuration, getting started, the error model, testing, limitations, layout.
Limitations are derived from the code rather than from memory — a section that
documented the four bugs fixed here would have shipped a lie.
AGENTS.md— the four defects moved from "Known issues" to "Alreadyfixed", with the reasoning kept; the rate-limiter entry corrected;
/healthdocumented as liveness-only and as shadowing a service on the same path.
Tests
158 → 177, all hermetic (miniredis + in-memory SQLite, no running services).
Coverage:
app71.4% → 73.4%,pkg/request97.4% → 97.6%.New coverage, one test per claim rather than per function:
TestProxyForwardsTheUpstreamStatus(201/404/500), andTestProxyReplaysTheCachedStatusfor the cache path — 418 twice, with theupstream hit counter proving the second came from Redis.
TestCall_SetValueRejectsAnImpossibleStatusfor the 100–999 guard.TestClient_ForwardsTheCallersHeaders,TestClient_StripsTheGatewayBearerAndHopByHopHeaders,TestClient_DoesNotAliasTheCallersHeaders,TestIsHopByHop,TestCopyUpstreamHeaders, andTestProxyDropsTheUpstreamConnectionHeaders.TestClient_ForwardsTheQueryString,TestProxyForwardsTheQueryString,TestNewCall_{PathRewriteKeepsTheQuery,PathRewriteWithoutAQuery,QueryDoesNotChangeTheMatch}.TestClient_CancelledCallerStopsTheUpstreamandTestClient_CachesAResponseTheCallerAlreadyHungUpOn— the pair that keeps theWithoutCancelexception honest.TestHealthIsAnsweredWithoutAToken,TestHealthIsNotShadowedByAService,TestHealthIsLogged.The two subtlest tests were mutation-checked rather than trusted: reverting
context.WithoutCancelfailsTestClient_CachesAResponseTheCallerAlreadyHungUpOn, and reverting the queryappend fails
TestClient_ForwardsTheQueryString.gofmt,go vet ./...,make build,go test ./... -raceanddocker compose configare all clean.Behaviour changes a reviewer should check
was relying on the old always-
200behaviour will see different statuses.written against a gateway which dropped them may behave differently.
observe fewer requests for abandoned traffic, which is the intent.
/healthis now taken. A service configured on thehealthpath becomesunreachable; registration order decides, and health is first.
guard only rejects payloads that would have panicked.
Known limitations left in place
Recorded rather than fixed, each because fixing it means changing behaviour or
adding a feature: responses are cached regardless of method; the upstream's
Content-Lengthis dropped; the caller'sAuthorizationis not forwarded andnothing replaces it;
NewCallstill deletesDatefrom the caller's live headermap (contained now, but the handler still observes the missing header);
server.hostis parsed and ignored;internal/externalis not apublic/private split;
ServicesConfighas noValidate(); Redis credentials arehardcoded.