DEV Community

Remdore
Remdore

Posted on AI-assisted

nginx will proxy the new HTTP QUERY method. It will never cache one.

LLMs built clients fine while nginx failed

The IETF published RFC 10008 back in June, and it adds a method to HTTP called QUERY. The short version is that it's a GET that's allowed to carry a body, so it's safe and idempotent like a GET, and it's cacheable, with the extra rule that whatever you put in the body has to end up in the cache key. The use case is the read whose parameters won't fit sensibly in a URL. Right now we all send those as POST and quietly accept that nothing downstream will ever cache them.

I've been curious whether any of the surrounding machinery is ready for it, so I spent a morning finding out. I wrote a search API that refuses every method except QUERY, put it on a small box in Frankfurt, and pointed ten language models at it to see whether they could produce a working client.

My assumption going in was that the models would be the weak link. That turned out to be wrong, and the thing that actually broke was the proxy.

The API

It searches the RFC index, which seemed like a fair dataset to pick given the circumstances. There are 9,836 entries in it. The filter document is exactly the shape RFC 10008 describes: optional substring matches on title and author, a year range, a list of acceptable statuses, a sort key, a limit.

Send it a GET or a POST and you get a 405 back, with an Allow: QUERY header and a body that names whichever method you tried. That rigidity is deliberate, because a server willing to also accept POST would have quietly absorbed every interesting failure instead of showing it to me.

$ printf '%s' '{"title_contains":"QUERY","limit":1}' \
  | curl -s -X QUERY --data-binary @- -H 'Content-Type: application/json' http://.../rfcs
{
  "total_matched": 21,
  "returned": 1,
  "results": [
    { "id": "RFC10008", "title": "The HTTP QUERY Method", "year": 2026, ... }
  ]
}
Enter fullscreen mode Exit fullscreen mode

When the prompt names the method, everything works

My first prompt was explicit about it: this endpoint takes one method, QUERY, from RFC 10008. Each model got a URL path of its own so that the server's log could tell me who sent what, and I ran every generated program rather than reading it and forming an opinion.

Eight models were reachable on my account. Three families came back 403 on this tier and one returned a 400, which is a lesson about inference platforms rather than about HTTP, so I left it alone.

Every one of the eight produced a program that sent a QUERY and printed the five RFCs I'd asked for. Nobody hedged, nobody fell back to POST. Here's the log:

QUERY /rfcs/llama-4-maverick        QUERY /rfcs/deepseek-v4-pro
QUERY /rfcs/mistral-3-14b           QUERY /rfcs/gemma-4-31b-it
QUERY /rfcs/glm-5-3-flash           QUERY /rfcs/openai-gpt-oss-120b
QUERY /rfcs/openai-gpt-oss-20b      QUERY /rfcs/minimax-m2-5
Enter fullscreen mode Exit fullscreen mode

Python helps here. urllib.request.Request(url, data=body, method='QUERY') is the whole trick, the standard library has never cared what you put in that argument, and all eight found it without visible effort.

When it doesn't, nobody guesses right

The version I actually wanted was the one where the method never comes up. So I rewrote the prompt the way a colleague would throw it at you in passing: here's a URL, here are the fields, it takes JSON, write me a client.

All eight sent POST.

None of them tried an OPTIONS request first. None of them treated the method as an open question at all, which is reasonable, because a JSON search endpoint has been POST for fifteen years and that prior is earned.

Two were cleverer than the rest about what happened next. deepseek-v4-pro and openai-gpt-oss-20b had both written code that caught the 405, looked at the Allow header while running, and retried using whatever it named. Neither was asked to. You can watch it in the log:

/r1/deepseek-v4-pro        POST -> QUERY
/r1/openai-gpt-oss-20b     POST -> QUERY
Enter fullscreen mode Exit fullscreen mode

Given the answer, half of them take it

Next I pasted each model the exact failure it had caused: status line, headers with Allow: QUERY, OPTIONS among them, and the JSON body spelling out the method it should have used. Fix your program.

Model What it did with the 405
deepseek-v4-pro switched to QUERY, worked
gemma-4-31B-it switched to QUERY, worked
openai-gpt-oss-120b switched to QUERY, worked
openai-gpt-oss-20b switched to QUERY, worked
llama-4-maverick used QUERY, moved the filters into the URL, sent no body, got 400
mistral-3-14B switched to GET, got another 405
glm-5.3-flash emitted a fragment that wouldn't parse
minimax-m2.5 emitted a syntax error

Four out of eight, from a response that hands over the answer in a header.

The llama attempt is the one I keep thinking about, because it changed the method correctly and then url-encoded all the filters into the query string and sent an empty body, which throws away the single reason the method exists. Mistral's is blunter. The server said Allow: QUERY and it went with GET, which the same server had refused thirty seconds earlier.

nginx will carry a QUERY, and will never cache one

This matters more than anything the models did.

Proxying is fine. Drop nginx in front of the API and every request goes through untouched. The trouble starts when you try to cache, which RFC 10008 explicitly permits:

$ nginx -t
nginx: [emerg] invalid value "QUERY" in /etc/nginx/sites-enabled/default:9
nginx: configuration file /etc/nginx/nginx.conf test failed
Enter fullscreen mode Exit fullscreen mode

proxy_cache_methods takes GET, HEAD and POST. You can't extend the list, and asking for anything else stops the server from starting.

The workaround anyone would try next is rewriting the method upstream with proxy_method POST and declaring POST cacheable. That fails too, and the reason is subtle: nginx tests proxy_cache_methods against what the client sent, not what it forwards. I checked by counting the requests that actually arrived at the backend, holding the config and the body constant and varying only the method on the wire.

What the client sent Client requests Requests that reached the backend
QUERY 4 4
POST 4 1

Sent as POST, three of the four never got past nginx. Sent as QUERY, the cache sat there doing nothing, and there isn't even a cache status header to hint that it declined.

Which leaves us somewhere silly. The method designed to be cached can't be, by the proxy most of the internet runs, while the method that isn't supposed to be cacheable is.

What I got wrong on the way

Three things, and I nearly published the second one as a result.

My server read Content-Length and stopped there. Node's http module doesn't set Content-Length for a method it has no opinion about, and reaches for Transfer-Encoding: chunked instead, so my server found an empty body, replied anyway, and left the chunks sitting unread in the socket. Whatever came next on that keep-alive connection parsed as rubbish, and node told me Parse Error: Expected HTTP/, RTSP/ or ICE/. I lost twenty minutes to believing node couldn't send a QUERY. It can. If you write one of these servers, handle chunked bodies.

To work out which method each program used, I grepped its source for method names. That reported three models picking GET in the no-hint round. The server log said all eight sent POST, every time. My regex had matched the word inside a comment or an unused error branch. The figure in this post comes from the log, because the log is a record of what happened and my grep was a record of what I assumed.

Then there's my own Content-Location header, which advertises /rfcs?limit=1&title_contains=QUERY. RFC 10008 offers that header as the GET-able equivalent a cache can hold onto. Mine answers 405 to a GET, so it's pointing at a door that doesn't open. I only spotted it while checking something unrelated.

Run it yourself

The server is roughly a hundred lines of standard library Python. The client is four:

import json, urllib.request
body = json.dumps({"title_contains": "HTTP", "year_from": 2020, "limit": 5}).encode()
req = urllib.request.Request(URL, data=body, method="QUERY",
                             headers={"Content-Type": "application/json"})
print(json.load(urllib.request.urlopen(req))["total_matched"])
Enter fullscreen mode Exit fullscreen mode

For the nginx behaviour, put any backend behind proxy_cache with proxy_cache_methods POST, send one body four times as POST and four times as QUERY, and count arrivals at the backend instead of reading headers:

for i in 1 2 3 4; do curl -s -o /dev/null -X QUERY --data-binary '{"a":1}' http://localhost/; done
for i in 1 2 3 4; do curl -s -o /dev/null -X POST  --data-binary '{"a":1}' http://localhost/; done
Enter fullscreen mode Exit fullscreen mode

I reached the models through DigitalOcean's inference endpoint, and the API sat on a 1-vCPU droplet in Frankfurt, so nothing in the model experiment went over loopback.

What to do about it

Clients are ready. Python, curl and node all send QUERY today without any ceremony, and the models writing your integration code will get it right as soon as your documentation mentions it. Don't skip that step. Eight out of eight assumed POST, and the only two that arrived at QUERY on their own got there by reading a 405 mid-flight.

It's the middle of the stack that will disappoint you. Count what reaches your backend before believing anything about caching, because nginx fails at this silently, with no error and no header, just every request sailing through. I only noticed because I gave up on headers and started counting.

And if caching is your reason for wanting QUERY at all, look at your proxy before you commit to the protocol. On nginx as it stands you can keep sending POST and cache it, or you can put something else in front. Neither is what the RFC had in mind, and a method nothing will cache is POST with better manners.

Top comments (14)

Collapse
 
raknaos profile image
Raknaos •

Ran into the same wall on a search-style API: the query bodies were 3-4 KB of filter JSON, way past any sane URL length, so we shipped it as POST and accepted the zero-caching penalty for a year.

Did you end up relying purely on proxy_cache_key with the body hash, or did you patch anything upstream? Asking because our nginx in front also terminates a CDN layer, and I'd expect Cloudflare/Fastly to be just as blind to QUERY as nginx proxy_cache was before 1.29.x. Curious how far down the chain the method actually survives today.

Collapse
 
remdore profile image
Remdore •

Neither. I didn't patch anything, and the body hash isn't where it breaks.

proxy_cache_key "$request_method$request_uri$request_body" parses fine and does nothing useful, because the gate is proxy_cache_methods, and that's checked against the method the client sent, not the one you forward. So proxy_method POST doesn't rescue it either. The cache key never gets consulted, because nginx decided the request wasn't cacheable before it looked.

On 1.29.x, I went and checked rather than guessing, because I'd only tested 1.24.0 and you might have been right. Installed 1.31.5, the current mainline from 2 September:

nginx: [emerg] invalid value "QUERY" in /etc/nginx/conf.d/default.conf:8
nginx: configuration file /etc/nginx/nginx.conf test failed
Enter fullscreen mode Exit fullscreen mode

Same error, same line of code. There's no QUERY or RFC 10008 entry anywhere in the CHANGES file through 1.31.5. There is a commit in the repo adding basic QUERY support upstream, so something is moving, but it isn't in a release you can apt-get today. Behaviour on 1.31.5 was identical to 1.24.0 for me: four identical QUERYs reached the backend four times, four identical POSTs reached it once.

On how far down the chain it survives, I probed a few edges just now with a QUERY and an empty JSON body:

Host Status Answered by
pypi.org (Fastly) 405 server: gunicorn
fastly.com 404 origin
cloudflare.com 405 server: cloudflare
developer.mozilla.org 502 via: 1.1 google, 1.1 varnish...

The pypi one is the useful datapoint. That 405 came from gunicorn, the origin, which means Fastly forwarded an unknown method to the backend rather than rejecting it at the edge. Cloudflare's own site returned 405 with server: cloudflare, and since Cloudflare fronts itself I can't tell edge from origin there, so I wouldn't read anything into it. MDN's Varnish chain gave me a 502, which is its own kind of answer.

So the method survives the hops and the thing it was standardised for doesn't happen at any of them. Your call to ship as POST and eat the caching penalty still looks right to me, and nothing between 1.24 and 1.31.5 changes it. The bit I couldn't reach is whether Cloudflare will actually store a QUERY response when you put a cache rule on it on purpose, since that needs a domain behind the edge, so if you run that against your own origin I'd rather see your numbers than guess at it.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

The POST fallback has a size boundary, and past it the failure looks exactly like the QUERY one. On 1.31.5 with proxy_cache_methods GET HEAD POST and proxy_cache_key "$request_method$request_uri$request_body", a 2KB and a 15KB body cache normally - MISS then HIT, two different bodies keyed apart - while a 17KB body gets no X-Cache header at all and every request lands on the backend, counted at the backend the way you suggest rather than read off headers. The gate is proxy_buffer_size, 16k by default: error.log carries proxy_buffer_size 16384 is not enough for cache key, it should be increased to at least 18432, and nothing about that reaches the client. Setting proxy_buffer_size 64k brought a 40KB body back to MISS then HIT, so the workaround holds only while URI plus body fits that buffer, which is an odd place for the limit to sit given a body too large for a URL is the reason to want QUERY at all.

Collapse
 
remdore profile image
Remdore •

Good find, and the knob is not the one you turned. $request_body is only populated when the body was read into a memory buffer, and that buffer is client_body_buffer_size. proxy_buffer_size sizes the buffer for the response header coming back from upstream, which is a different direction entirely. Once the body spills to a temp file $request_body is empty, so your key quietly degrades from method plus URI plus body to method plus URI.

Your boundary also tells you something about your host. The documented default is "8k|16k", two memory pages, which is 8K on x86-64 and usually 16K on other 64-bit platforms. 15KB passing and 17KB failing puts you on 16K pages, so arm64. Anyone reproducing this on x86-64 hits the same wall at 8KB, which is an unpleasant thing to have vary by architecture.

The part I would check before trusting the workaround: with $request_body empty, two different bodies to the same URI produce an identical key. You saw MISS, which suggests nginx declines to store it, and that is the good outcome. If it ever stores one, the failure stops being a missing cache and becomes a HIT with someone else's response. Worth pinning down, because that is the difference between slow and wrong.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

The page size call is right: getconf PAGESIZE gives 16384 here, arm64. But $request_body_file separates two walls that are not the same one. On the default client_body_buffer_size, bodies of 2KB, 15KB, 32768, 32769 and 40960 all log bodyfile=-, and two different bodies to the same URI both come back MISS with different upstream responses, so the body is still in the key at 40KB; the 17KB failure had its own error.log line naming proxy_buffer_size and nothing about a temp file.

Your mechanism shows up higher, and it is worse than the good outcome you hoped for. At 65536 the body does go to a temp file, and nginx does not decline to store it: the second request, with a completely different body to the same URI, comes back HIT carrying the first body's response. Same at 131072 and 262144. Forcing client_body_buffer_size 1k reproduces it at 4KB while a 512-byte body still keys apart, so the variable really is the buffer rather than the absolute size.

Which makes proxy_buffer_size 64k the move that walks you off the loud wall and onto the silent one. On this build the boundary sits between 40960 and 65536, and past it the cache is not missing, it is wrong.

Collapse
 
anshu_pathak_11 profile image
Anshu Pathak •

It's interesting that nginx forwards the new HTTP QUERY method but, despite RFC 10008 marking QUERY as cacheable, it doesn't store any responses, as you saw with the four backend hits. Do you know if there's a specific nginx directive or module that needs to be enabled to make QUERY responses cacheable, or is this a limitation of the current version?

Collapse
 
remdore profile image
Remdore •

It is a limitation, not a directive you are missing. proxy_cache_methods only accepts GET, HEAD and POST, and there is no module or flag that extends that list. Naming QUERY there does not fail quietly either, it stops nginx from starting:

nginx: [emerg] invalid value "QUERY" in /etc/nginx/conf.d/default.conf
Enter fullscreen mode Exit fullscreen mode

I checked this on 1.31.5, the current mainline from early September, not just the older version in the post, and it is the same. There is a commit in the nginx source tree adding basic QUERY support, so it is coming, but it is not in any release you can install today. Until it ships, the method reaches your backend and nothing along the way will cache it, whatever you put in the config.

Collapse
 
anshu_pathak_11 profile image
Anshu Pathak •

Ah, thank you Remdore, btw I'm Anshu from ZyVOP (zyvop.com)

Collapse
 
alexshev profile image
Alex Shev •

The cacheability gap is a good reminder that a new protocol semantic is only useful when the layers around it preserve that semantic. I would document the intended cache key explicitly in the test fixture; otherwise a later proxy change can claim QUERY support while still collapsing distinct bodies.

Collapse
 
remdore profile image
Remdore •

Agreed, and it is worse than collapsing bodies into one entry: it serves one body's response to a different body, which is a correctness bug rather than a missed optimisation. A cache miss costs you latency. This costs you the wrong answer.

I built the exact broken proxy you describe, one that keys on method plus path and ignores the body, and put it in front of the QUERY server. Two different searches to the same path:

body={"title_contains":"TLS"}   returned RFC9768  (congestion notification)
body={"title_contains":"QUIC"}  returned RFC9768  (congestion notification)
Enter fullscreen mode Exit fullscreen mode

Both got the same wrong RFC, whichever body happened to be cached first. Straight to the server, the same two bodies return RFC9846 and RFC9368 as they should. RFC 10008 makes the body part of the cache key mandatory precisely to stop this, and that is the line a "we support QUERY now" proxy is most likely to get wrong.

You are right that my fixture is too weak here. It asserts the cache never engaged, because nginx refused the config outright, but it never asserts the positive: that two distinct bodies to the same URL produce two distinct cache entries. That negative test passes on a proxy that supports QUERY badly. The body-distinction assertion is the one that actually catches a regression, so I will add it.

Collapse
 
jo-do profile image
Jo Do •

The assumption that models would be the weak link breaking on infrastructure instead deserves its own post. It matches what I've seen: the spec-reading part of the task is now the cheap part, and the scarce skill is knowing which layer of the stack hasn't heard the news yet.

The failure mode to file away: proxies rarely reject the unfamiliar method - they silently lose its semantics (cached as GET, body dropped from the key). And the ten-model client test is a nice trick: "can models produce a client for X" is a fast proxy for "is X documented in the shape the corpus expects."

Collapse
 
remdore profile image
Remdore •

The failure mode is close to yours but inverted, and the inversion is the interesting part. nginx does not cache a QUERY as a GET. It does not cache it at all, and it never mentions that it decided this.

proxy_cache_methods takes GET, HEAD and POST and nothing else. Handing it QUERY is not a silent downgrade, it refuses to start: invalid value "QUERY". The workaround anyone reaches for next, rewriting to POST upstream with proxy_method and declaring POST cacheable, fails too, because nginx tests the method the client sent rather than the one it forwards. Holding the config and the body constant and counting what actually reached the backend: four QUERY requests arrived four times, four POSTs arrived once.

So the method written to be cacheable is the one that cannot be, the method that is not supposed to be cacheable is, and there is no cache status header hinting that anything declined.

Your wider point holds though. The spec-reading really was the cheap part here: eight of eight models sent a correct QUERY once the method was named, and the layer that had not heard the news was the proxy.

Collapse
 
hlinhbuilds profile image
H. Lin /h-lin/ •

Back in 2021–2022, I used to use NGINX all the time and was constantly configuring all sorts of things... but these days I use CF for pretty much everything, so I don't remember much about NGINX anymore

Some comments may only be visible to logged-in visitors. Sign in to view all comments.