600 Filters and a 414: The New QUERY Method in .NET 10
The 8 KB wall
A product search, a filter list that kept growing, and a status code I hadn't seen in years. Filters went in the query string, the way they always do. That held up fine until someone saved a "filter set" with a few hundred SKUs in it and the endpoint started answering with 414. I rebuilt a small version of it to find the exact wall. Same search, filters as repeated ?sku= values, count going up in steps of a hundred:
- GET with filters in the URL
- 100 filters | request line 1534 bytes | 200 OK
- 200 filters | request line 3034 bytes | 200 OK
- 300 filters | request line 4534 bytes | 200 OK
- 400 filters | request line 6034 bytes | 200 OK
- 500 filters | request line 7534 bytes | 200 OK
- 600 filters | request line 9034 bytes | 414 RequestUriTooLong
Kestrelโs default max request line is 8 KB, and the request line is the method plus the URL plus the HTTP version. Somewhere between 500 and 600 filters, my URL stopped being a URL.
Every fix I knew was a compromise. A body on GET is undefined by spec and some proxies quietly drop it. POST works, but POST announces "this might change something", so caches skip it, gateways wonโt auto-retry it, and anyone reading your API docs has to guess whether POST /search is actually a search. Cramming the filters into a header is the kind of idea that sounds clever for about a day.
The method that was missing
RFC 10008 defines QUERY, and itโs exactly the thing that spot in the matrix was waiting for. The body carries the query. The method is safe and idempotent, so it can be retried after a dropped connection without anyone panicking. Responses are cacheable, and the spec is explicit that the cache key has to be built from "the request content and related metadata". Thereโs also a nice touch on the response side: Content-Location can point at a URL where those exact results can be fetched with a plain GET. The one-line version I keep giving people: itโs a GET with a body, and thatโs the entire point.
Wiring it up in ASP.NET Core 10
.NET 10 ships the primitives but not the sugar. Thereโs no MapQuery and no [HttpQuery] attribute, so MapMethods is the door in:
app.MapMethods("/products/search", [HttpMethods.Query], async (HttpContext ctx) =>
{
var filter = await JsonSerializer.DeserializeAsync<Filter>(ctx.Request.Body);
var result = RunSearch(filter!.Skus, filter.MaxPrice);
ctx.Response.Headers.ContentLocation = $"/products/search-results/{result.Execution}";
return Results.Ok(result);
});
HttpMethods.Query and HttpMethods.IsQuery are real APIs now, and IsQuery("query") returns true because the canonicalizer handles casing. On the client side, HttpMethod.Query exists too:
using var req = new HttpRequestMessage(HttpMethod.Query, "/products/search");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await http.SendAsync(req);
Kestrel already parses the verb, so nothing else needed configuring.
The QUERY method in action
I threw the same filter list at it, except this time with 5,000 SKUs instead of the 600 that broke the URL:
- QUERY with filters in the body
- body 65025 bytes | 200 OK | {"matches":3752,"execution":6}
- Content-Location: /products/search-results/6
65 KB of filters, no 414, no argument about it.
One rule you have to enforce yourself
The spec is blunt here: servers MUST fail the request if Content-Type is missing or inconsistent with the content. ASP.NET Core wonโt do that for you on a MapMethods handler, so itโs four lines you write once:
if (string.IsNullOrEmpty(ctx.Request.ContentType))
return Results.Problem("QUERY requires a Content-Type.", statusCode: 400);
if (!ctx.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
- Content-Type handling
- no Content-Type -> 400 BadRequest
- text/plain -> 415 UnsupportedMediaType
- application/json -> 200 OK
Missing gets a 400, wrong media type gets a 415. Easy to skip, and skipping it means the first client that forgets a header gets a deserialization exception instead of an answer.
Where I got it wrong
The spec says QUERY responses are cacheable. So I slapped .CacheOutput() on all three endpoints, sent each one three times, and had the server report how many times the search actually ran:
- Output caching (execution number only moves on a real hit)
- GET x3 -> executions 8, 8, 8 => cached
- POST x3 -> executions 9, 10, 11 => NOT cached
- QUERY x3 -> executions 12, 13, 14 => NOT cached
GET cached. QUERY didn't. Output caching only considers GET and HEAD cacheable, which is fair enough given QUERY didn't exist when that code was written. So I wrote a custom IOutputCachePolicy that opts QUERY in, one of those twenty-line classes that feels like a clean fix. It cached. Then I sent two different bodies to the same URL:
- Custom policy that caches QUERY (same URL, two different bodies)
- body A (2 skus) -> {"matches":2,"execution":15}
- body B (4 skus) -> {"matches":2,"execution":15}
Body B asked about four SKUs and got body A's answer, straight from the cache, execution number and all. The output cache key comes from the URL and the vary-by rules. For QUERY the URL isn't the query, the body is, so my policy taught the cache to key on the one part of the request that no longer carried any information. Doing this properly means hashing normalized request content into the key, which is exactly what the RFC talks about and exactly what ASP.NET Core's output caching gives you no hook for today.
My take: don't hand-roll it. Leave QUERY uncached until the framework can key on the body, because a cache that returns confidently wrong search results is worse than no cache.
So is it worth using now?
For an internal API between services you control, yes, and the URL length thing alone justifies it. You also get honest semantics for free, which matters more than it sounds: because .NET treats QUERY as idempotent, resilience policies will retry it the way they retry GET, and nobody has to argue about whether retrying POST /search is safe.
For a public API, be more careful. I only tested this inside one container against Kestrel. I have no idea what your CDN, WAF or corporate proxy does with an unfamiliar verb, and that's worth finding out before you ship it rather than after. OpenAPI 3.1 has no query field on a path item either, so don't promise a client team that these endpoints will show up in your generated document without checking. Keeping the POST route alive alongside it costs almost nothing and buys you an escape hatch.
Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/003-http-query-method
Has anyone tried pushing a QUERY request through a real CDN yet? I'd like to hear what came back.
- Still measuring things nobody asked me to measure
Comments
No comments yet. Start the discussion.