From 83cfccc04afd04de73bfdc92cf604d97e57244e4 Mon Sep 17 00:00:00 2001
From: Matteo Collina <hello@matteocollina.com>
Date: Mon, 29 Jun 2026 12:44:19 +0000
Subject: [PATCH 1/3] Support HTTP QUERY method (RFC 10008)

Add QUERY to normalized method records, safe HTTP methods, idempotent
default, retryable methods, and fetch safe methods.

Closes #5454
---
 lib/core/request.js             | 2 +-
 lib/core/util.js                | 6 ++++--
 lib/handler/redirect-handler.js | 1 +
 lib/handler/retry-handler.js    | 2 +-
 lib/web/fetch/constants.js      | 2 +-
 5 files changed, 8 insertions(+), 5 deletions(-)

diff --git a/lib/core/request.js b/lib/core/request.js
index 2d2675a5065..8e2072a46c7 100644
--- a/lib/core/request.js
+++ b/lib/core/request.js
@@ -225,7 +225,7 @@ class Request {
     this.protocol = getProtocolFromUrlString(origin)
 
     this.idempotent = idempotent == null
-      ? method === 'HEAD' || method === 'GET'
+      ? method === 'HEAD' || method === 'GET' || method === 'QUERY'
       : idempotent
 
     this.blocking = blocking ?? this.method !== 'HEAD'
diff --git a/lib/core/util.js b/lib/core/util.js
index 1f6611d6404..f95122f5c1f 100644
--- a/lib/core/util.js
+++ b/lib/core/util.js
@@ -960,7 +960,9 @@ const normalizedMethodRecordsBase = {
   post: 'POST',
   POST: 'POST',
   put: 'PUT',
-  PUT: 'PUT'
+  PUT: 'PUT',
+  query: 'QUERY',
+  QUERY: 'QUERY'
 }
 
 const normalizedMethodRecords = {
@@ -1014,7 +1016,7 @@ module.exports = {
   normalizedMethodRecords,
   isValidPort,
   isHttpOrHttpsPrefixed,
-  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
+  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']),
   wrapRequestBody,
   setupConnectTimeout,
   getProtocolFromUrlString
diff --git a/lib/handler/redirect-handler.js b/lib/handler/redirect-handler.js
index 8ead972b742..fd2158302e5 100644
--- a/lib/handler/redirect-handler.js
+++ b/lib/handler/redirect-handler.js
@@ -55,6 +55,7 @@ class RedirectHandler {
     // https://tools.ietf.org/html/rfc7231#section-6.4.2
     // https://fetch.spec.whatwg.org/#http-redirect-fetch
     // In case of HTTP 301 or 302 with POST, change the method to GET
+    // QUERY is safe (RFC 10008) and should not change method like GET.
     if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') {
       this.opts.method = 'GET'
       if (util.isStream(this.opts.body)) {
diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js
index 7fbc1a6ad77..7928d447ee6 100644
--- a/lib/handler/retry-handler.js
+++ b/lib/handler/retry-handler.js
@@ -67,7 +67,7 @@ class RetryHandler {
       timeoutFactor: timeoutFactor ?? 2,
       maxRetries: maxRetries ?? 5,
       // What errors we should retry
-      methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
+      methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY'],
       // Indicates which errors to retry
       statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
       // List of errors to retry
diff --git a/lib/web/fetch/constants.js b/lib/web/fetch/constants.js
index ef63b0c8e10..b39a0f594e5 100644
--- a/lib/web/fetch/constants.js
+++ b/lib/web/fetch/constants.js
@@ -46,7 +46,7 @@ const referrerPolicyTokensSet = new Set(referrerPolicyTokens)
 
 const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error'])
 
-const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE'])
+const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'])
 const safeMethodsSet = new Set(safeMethods)
 
 const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors'])

From 9cf7fa285fc0995c0fb2075dd5c45b76d352bd4b Mon Sep 17 00:00:00 2001
From: Matteo Collina <hello@matteocollina.com>
Date: Tue, 30 Jun 2026 11:07:16 +0000
Subject: [PATCH 2/3] test: add QUERY method tests

Add comprehensive tests for HTTP QUERY (RFC 10008):
- QUERY with string, Buffer, stream bodies
- QUERY without body and with null body
- Content-Type and Accept-Query headers
- Content-Length header sending
- Content-Length mismatch error handling
- Fetch API with QUERY method
- FormData body with QUERY
- Redirect 301 (QUERY stays QUERY - safe method)
- Redirect 303 (QUERY changes to GET)
---
 test/query.js | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 298 insertions(+)
 create mode 100644 test/query.js

diff --git a/test/query.js b/test/query.js
new file mode 100644
index 00000000000..784b5f98a6d
--- /dev/null
+++ b/test/query.js
@@ -0,0 +1,298 @@
+'use strict'
+
+const assert = require('node:assert')
+const { test } = require('node:test')
+const { createServer } = require('node:http')
+const { once } = require('node:events')
+const { Readable } = require('node:stream')
+const { Client, errors, fetch, FormData } = require('..')
+
+test('QUERY with string body sends correctly', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    // Consume body to ensure it arrives
+    for await (const _ of req) {} // eslint-disable-line
+    res.writeHead(200, { 'content-type': 'application/json' })
+    res.end('{"data":{}}')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    headers: { 'content-type': 'application/json' },
+    body: '{"query":"{user}"}'
+  })
+  await response.body.text()
+})
+
+test('QUERY with Buffer body sends correctly', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    // Consume body to ensure it arrives
+    for await (const _ of req) {} // eslint-disable-line
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: Buffer.from('hello')
+  })
+  await response.body.text()
+})
+
+test('QUERY with stream body sends correctly', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    // Consume body to ensure it arrives
+    for await (const _ of req) {} // eslint-disable-line
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: new Readable({
+      read () {
+        this.push('streamed')
+        this.push(null)
+      }
+    })
+  })
+  await response.body.text()
+})
+
+test('QUERY without body works', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: ''
+  })
+  await response.body.text()
+})
+
+test('QUERY with null body works', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: null
+  })
+  await response.body.text()
+})
+
+test('QUERY sends Content-Type header', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    headers: { 'content-type': 'application/json' },
+    body: '{}'
+  })
+  await response.body.text()
+})
+
+test('QUERY can send Accept-Query header', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    headers: { 'accept-query': 'application/graphql' },
+    body: '{}'
+  })
+  await response.body.text()
+})
+
+test('QUERY sends content-length header', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    path: '/',
+    method: 'QUERY',
+    body: 'hello'
+  })
+  await response.body.text()
+})
+
+test('QUERY with content-length mismatch should error', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    res.end()
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  try {
+    await client.request({
+      path: '/',
+      method: 'QUERY',
+      headers: { 'content-length': 10 },
+      body: 'asd'
+    })
+    assert.fail('should have thrown')
+  } catch (err) {
+    assert.ok(err instanceof errors.RequestContentLengthMismatchError)
+  }
+})
+
+test('QUERY method is recognized as safe in fetch API', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+  t.after(() => server.close())
+
+  const resp = await fetch(`http://localhost:${server.address().port}/`, {
+    method: 'QUERY',
+    headers: { 'content-type': 'text/plain' },
+    body: 'hello'
+  })
+  assert.strictEqual(resp.status, 200)
+  const text = await resp.text()
+  assert.strictEqual(text, 'ok')
+})
+
+test('QUERY with FormData body works', async (t) => {
+  const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
+    // Consume body to ensure it arrives
+    for await (const _ of req) {} // eslint-disable-line
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`)
+  t.after(() => { client.destroy(); server.close() })
+
+  const fd = new FormData()
+  fd.append('field', 'value')
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: fd
+  })
+  await response.body.text()
+})
+
+test('QUERY redirect 301 should NOT change method (QUERY is safe)', async (t) => {
+  let redirects = 0
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    redirects++
+    if (redirects === 1) {
+      res.writeHead(301, { location: '/redirected' })
+      res.end()
+      return
+    }
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`, {
+    maxRedirections: 1
+  })
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: 'hello',
+    headers: { 'content-type': 'text/plain' }
+  })
+  await response.body.text()
+})
+
+test('QUERY redirect 303 should change method to GET', async (t) => {
+  let redirects = 0
+  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
+    redirects++
+    if (redirects === 1) {
+      res.writeHead(303, { location: '/redirected' })
+      res.end()
+      return
+    }
+    res.writeHead(200)
+    res.end('ok')
+  })
+
+  await once(server.listen(0), 'listening')
+
+  const client = new Client(`http://localhost:${server.address().port}`, {
+    maxRedirections: 1
+  })
+  t.after(() => { client.destroy(); server.close() })
+
+  const response = await client.request({
+    method: 'QUERY',
+    path: '/',
+    body: 'hello',
+    headers: { 'content-type': 'text/plain' }
+  })
+  await response.body.text()
+})

From 7a574ee379ceba6fbd6038f76dba588ac69f938d Mon Sep 17 00:00:00 2001
From: Matteo Collina <hello@matteocollina.com>
Date: Tue, 30 Jun 2026 13:20:14 +0000
Subject: [PATCH 3/3] fix: remove QUERY from safeHTTPMethods
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

QUERY responses depend on the request body (RFC 10008 §2.7), but the
cache/deduplicate interceptors' cache keys ignore the request body.
Removing QUERY from safeHTTPMethods prevents these interceptors from
accepting QUERY, avoiding serving wrong responses.

The fetch API's separate safeMethods list in web/fetch/constants.js
still includes QUERY, which is correct for cache invalidation purposes.
---
 lib/core/util.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/core/util.js b/lib/core/util.js
index f95122f5c1f..cdb46d651e6 100644
--- a/lib/core/util.js
+++ b/lib/core/util.js
@@ -1016,7 +1016,7 @@ module.exports = {
   normalizedMethodRecords,
   isValidPort,
   isHttpOrHttpsPrefixed,
-  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']),
+  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
   wrapRequestBody,
   setupConnectTimeout,
   getProtocolFromUrlString
