- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| cmd | ||
| internal | ||
| .gitignore | ||
| config.example.json | ||
| go.mod | ||
| LICENSE | ||
| mise.toml | ||
| README.md | ||
my-http-reverse-proxy
Context
This is meant to be a fun practice exercise, not something built for production.
It's inspired by the implementation of net/http/httputil.ReverseProxy.
I took reference from:
Features
- Routes requests to different upstream backends by URL path prefix (e.g.
/api/-> one backend,/test/-> another,/-> default). - Forwards
X-Forwarded-For,X-Forwarded-Proto,X-Forwarded-Hostand strips hop-by-hop headers in and out. - Caches GET responses in memory for a per-route TTL, adding an
X-Cacheheader (MISS/HIT/BYPASS) and anAgeheader on hits. - Safely bypasses the cache for sensitive or negotiated data (respects
Cache-Control: private/no-store, clientCookie, and serverSet-Cookie/Varyheaders).
Project Structure
cmd/
revproxy/ main binary: loads config, wires routing + cache, listens
fakeupstream/ throw-away backend for local testing (spins up N ports, each with /hello, /headers, /counter)
internal/
revproxy/ single-upstream reverse proxy handler (RevProxy)
cache/ in-memory cache middleware (Middleware, Store, Entry)
config/ JSON config loading (Config)
Getting Started
I built this with Go 1.27.1. I use mise to manage Go's version. Check it out if you are interested, it's pretty cool.
fakeupstream will probably break if you use a version older than 1.25 since I use the sync.WaitGroup.Go() function.
A JSON config file is required to be passed via the -config flag.
{
"listen": ":8000",
"routes": [
{
"prefix": "/",
"upstream": "http://localhost:9000",
"ttl": "5s"
},
{
"prefix": "/api/",
"upstream": "http://localhost:9001",
"ttl": "10s"
},
{
"prefix": "/test/",
"upstream": "http://localhost:9002",
"ttl": "1m"
}
]
}
listen is an address for http.ListenAndServe.
routes is a list of prefix/upstream/ttl entries. prefix needs a trailing slash to subtree-match (see net/http.ServeMux rules), upstream is the backend base URL, and ttl (parsed with time.ParseDuration) is that route's cache TTL.
You can use config.example.json to test it out with fakeupstream.
The config matches fakeupstream default options.
If you want you can override the port (-port) or the number of backends (-replicas) via fakeupstream flags, but be careful to update the config to match if you do.
Demo
First, you'll want to start three fake backends:
go run ./cmd/fakeupstream
Then, in another terminal, start the proxy:
go run ./cmd/revproxy -config=./config.example.json
If you make a request, the first one will be a cache miss and gets forwarded to the upstream backend:
curl -i http://localhost:8000/api/counter
# HTTP/1.1 200 OK
# X-Cache: MISS
# ...
# count is: 1
If you repeat that request within the TTL (10s for /api/ in the example config), it will be served directly from the cache. You won't hit the upstream, and you can see the Age header gets added:
curl -i http://localhost:8000/api/counter
# HTTP/1.1 200 OK
# X-Cache: HIT
# Age: 1
# ...
# count is: 1
Try hitting a different route prefix to reach a different upstream counter. This proves that the routes don't collide in the shared cache:
curl -i http://localhost:8000/counter
# X-Cache: MISS
# count is: 1
Finally, if you wait past the 10-second TTL and make the original request again, you'll see an X-Cache: MISS and the count will increment.
Known limitations
Since this is just a practice project, I left a few things out:
- It ignores
Cache-ControlTTL directives (max-age/s-maxage), relying entirely on the fixed TTL. - The Store only evicts an expired entry lazily, on the next request for that same key. Keys that go cold after expiring just sit there, so it can still grow unbounded over a long uptime.
- I used http.ListenAndServe directly, which means there are no timeouts or graceful shutdowns configured.
- It only caches GET requests, and there is no cap on the cache size.