DEV Community

Remdore
Remdore

Posted on AI-assisted

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

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 (4)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

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
 
hlinhbuilds profile image
H. Linh /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.