querygate is a small Go middleware and reverse proxy for teams that want to adopt the HTTP QUERY method without turning safe lookups into accidental POST APIs.
The short version: when a read request needs a body, GET stops being a good fit and POST tells too little of the truth. QUERY keeps the semantics readable: the request is safe, idempotent, body-aware, and cacheable when the response allows it.
querygate gives you two pieces that can be used together or separately:
| Piece | Use it when | What it gives you |
|---|---|---|
query package |
You own a Go net/http service |
QUERY validation, Accept-Query discovery, correct OPTIONS behavior, and content negotiation checks |
querygate proxy |
You need to put something in front of an existing service | Body-aware caching for QUERY, safe cache defaults, health checks, request IDs, and an optional QUERY to POST bridge |
The project is intentionally boring in the places that matter. It uses the Go standard library, has bounded request and cache sizes, ships with timeouts, and refuses to cache responses that look private or ambiguous.
Many APIs start with GET /search?q=.... Then filters get larger, clients need structured bodies, and the endpoint quietly becomes POST /search even though nothing is being created or changed.
That workaround costs you useful HTTP semantics:
| Method | Request body | Intended meaning | Cache story |
|---|---|---|---|
GET |
Not a practical place for payloads | Safe lookup through the URI | Mature, URI-keyed |
QUERY |
Yes | Safe, idempotent lookup with request content | Body-aware cache key |
POST |
Yes | Often means mutation, sometimes does not | Usually skipped by shared caches |
querygate exists for the middle case: large or structured reads that should still be treated as reads.
Run the example backend in one terminal:
go run ./examples/backendRun the proxy in another:
go run ./cmd/querygate \
--listen :8080 \
--upstream http://127.0.0.1:9000Send the same QUERY twice:
curl -i -X QUERY http://127.0.0.1:8080/search \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
--data '{"q":"latency budget","limit":3}'The first response should include:
X-Querygate-Cache: MISSThe repeated response should include:
X-Querygate-Cache: HIT
Age: <seconds>The exact Age value will depend on how quickly you repeat the request.
Install the CLI from the module path:
go install github.com/kaluga/querygate/cmd/querygate@latestOr build the local checkout:
make build
./querygate --listen :8080 --upstream http://127.0.0.1:9000The Docker image is intentionally plain:
make docker
docker run --rm -p 8080:8080 querygate:dev \
--upstream http://host.docker.internal:9000Use the query package when your Go service can handle QUERY directly.
package main
import (
"log"
"net/http"
"github.com/kaluga/querygate/query"
)
func main() {
search := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=60")
_, _ = w.Write([]byte(`{"results":[]}` + "\n"))
})
handler, err := query.NewHandler(search, query.Options{})
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":9000", handler))
}By default, the middleware accepts application/json, application/x-www-form-urlencoded, and text/plain request bodies, and advertises JSON responses through Accept-Query.
For QUERY requests, the proxy builds a cache key from the method, URI, selected request headers, media type, content encoding, and normalized body. A repeated request with the same meaning can then be served from memory without touching the upstream.
The cache is conservative by design. It skips storage for responses with Set-Cookie, private, no-store, no-cache, Vary: *, unsupported Vary headers, oversized entries, missing cacheability signals, and credentialed requests unless the response is explicitly public.
Unsafe requests such as POST, PUT, PATCH, and DELETE pass through to the upstream. Successful unsafe requests invalidate cached entries for the same URI.
Some backends will not speak QUERY on day one. With translate_on_405 enabled, querygate retries a QUERY request as POST when the upstream returns 405 Method Not Allowed.
Translated responses are not cached by default. You can opt in with cache_translated: true, but do that only when the legacy POST handler is genuinely safe and its responses are written for shared caching.
querygate --listen :8080 --upstream http://127.0.0.1:9000
querygate --config config.example.json
querygate --config config.example.json --config-check
querygate probe http://127.0.0.1:8080/search
querygate --versionReserved endpoints:
| Endpoint | Response |
|---|---|
GET /healthz |
ok |
GET /readyz |
ready |
Configuration is loaded in this order:
- Built-in defaults
- JSON config file
QUERYGATE_environment variables- CLI flags for
--listenand--upstream
See config.example.json for a complete file.
| Field | Default |
|---|---|
listen |
:8080 |
upstream |
http://127.0.0.1:9000 |
cache_capacity |
1024 |
ttl |
60s |
translate_on_405 |
true |
cache_translated |
false |
max_request_body_bytes |
1048576 |
max_request_header_bytes |
65536 |
max_cache_bytes |
67108864 |
max_entry_bytes |
5242880 |
read_header_timeout |
5s |
read_timeout |
30s |
write_timeout |
60s |
idle_timeout |
120s |
upstream_timeout |
30s |
shutdown_timeout |
10s |
vary_headers |
["Accept", "Accept-Encoding"] |
log_level |
info |
log_format |
text |
Environment variables use the same names in screaming snake case: QUERYGATE_TTL, QUERYGATE_UPSTREAM, QUERYGATE_VARY_HEADERS, and so on.
querygate is a v1 adoption layer, not a full API gateway. It does not do TLS termination, authentication, rate limiting, persistent cache storage, distributed invalidation, tracing, or load balancing. Put it behind the infrastructure you already trust for those jobs.
What it does try to get right:
- bounded request bodies, response entries, headers, and total cache size
- sane server and upstream timeouts
- request IDs on proxied responses
Ageon cached responses- conditional responses for cached
ETagandLast-Modifiedentries - explicit cache status through
X-Querygate-Cache
| Symptom | Likely cause |
|---|---|
400 Bad Request |
Non-empty QUERY body without Content-Type, malformed Content-Type, or malformed gzip body |
406 Not Acceptable |
The Accept header does not match a configured response type |
413 Request Entity Too Large |
The request body exceeded max_request_body_bytes |
415 Unsupported Media Type |
Unsupported request media type or unsupported Content-Encoding |
X-Querygate-Cache: SKIP |
The response was not safe or useful to store |
Repeated MISS |
Check Cache-Control, credentials, Set-Cookie, Vary, body size, request headers, and response status |
make fmt
make test
make race
make vetThe module has no third-party Go dependencies. That is deliberate: the cache and proxy code should stay easy to read in a code review.
The near-term backlog is focused on production ergonomics without changing the core shape of the project:
- singleflight for duplicate in-flight cache misses
- Prometheus text metrics
- richer
probeoutput - fuzz tests for parsing and cache-key normalization
- Redis or Memcached cache implementations
- OpenTelemetry instrumentation
- framework adapters once the
net/httpsurface has settled
Please report security issues privately. See SECURITY.md.
MIT. See LICENSE.