標籤
白色卡片上的 IETF 標誌,黃色訊號線在灰色菱形上呈鋸齒狀,下方為黑色 IETF 字樣

用 Python 實作 RFC 10008 的 HTTP QUERY 方法:它和 GET、POST 差在哪

以 Python 與 Starlette 實作 RFC 10008 的 HTTP QUERY:帶結構化請求主體,同時安全、冪等、可快取的請求,以及它與 GraphQL 的互補關係。

本頁目錄

引言

2026 年 6 月,一個新的 HTTP 方法 QUERY 正式標準化為 RFC 10008。這個方法取了 GET 與 POST 的優點,我實際用 Python 寫了一個伺服器並跑起來看看。

https://github.com/oharu121/http-query-method-rfc10008-demo

TL;DR

GET / POST / QUERY — 請求流程與快取行為的比較三列圖,每列追蹤一個方法從用戶端經過代理到伺服器。GET 只送出 URL,可依 URI 快取,安全且冪等,但 URL 長度上限約兩千字元且無法巢狀。POST 送出 body,不會被快取,每次都轉送,重試有風險,且語意上代表變更狀態但實際並非如此。QUERY 送出 body,以 URI 與 body 的雜湊為鍵快取,安全且冪等:兼具 GET 的安全性與 POST 的結構化 body。GET / POST / QUERY — 請求流程與快取行為的比較ClientProxy / CDNServerGETGET /search?cat=laptops&…只有 URLCache HIT?可依 URI 快取MISS 時Safe + Idempotent可安全自動重試URL 長度上限約 2,048 字元,且無法表達巢狀結構POSTPOST /searchJSON body帶 bodyCache SKIP無法快取(視為 unsafe)每次轉送Unsafe重試有風險語意上代表變更狀態,與實際行為不符QUERYQUERY /searchJSON body帶 bodyCache HIT?以 URI + body 雜湊為鍵快取MISS 時Safe + Idempotent可安全自動重試GET 的安全性 + POST 的結構化 body = QUERY(RFC 10008)圖例可快取無法快取帶 body 也可快取
同時具備結構化主體與可快取、安全請求的,只有 QUERY。

QUERY 方法是什麼

在 HTTP 上設計搜尋 API 時,開發者長年抱著這個兩難。

特性 GET POST QUERY
請求主體
安全 Yes No Yes
冪等 Yes No Yes
可快取 Yes 受限 Yes
  • GET 安全、冪等、可快取,但不能帶主體。複雜的搜尋條件只能塞進 URL 查詢字串,於是要與 URL 長度上限(實務上約 2048 個字元)和巢狀結構的表達方式搏鬥
  • POST 可以帶主體,但語意是「會改變狀態的操作」。代理伺服器與快取會判定它「有副作用」,因此快取不易生效,自動重試也不安全

QUERY 這個方法在維持 GET 的安全性、冪等性、可快取性的同時,還能像 POST 一樣送出結構化的請求主體。

Safe、Idempotent、Cacheable 是什麼,為什麼重要

針對上表出現的三個特性,以下逐一具體說明。

Safe(安全)

指送出請求不會改變伺服器的狀態。用 GET 讀一個頁面是安全的,用 DELETE 刪一筆記錄則不是。

為什麼重要:只要方法是「安全的」,瀏覽器、爬蟲、預先擷取機制就會自由地送出請求。2005 年 Google Web Accelerator 預先擷取連結時,偽裝成 GET 的 DELETE 操作被觸發,使用者的資料因此被刪除。「安全」這個宣告,是整個基礎設施自動化不可或缺的訊號。

比較安全的 GET /page 與不安全的 DELETE /users/123,並顯示瀏覽器、爬蟲、預先擷取機制自由送出不會改變伺服器狀態的請求

Idempotent(冪等)

指同一個請求送 1 次和送 100 次結果都相同。GET、PUT、DELETE 是冪等的。POST 不是(付款送兩次,就可能被扣兩次款)。

為什麼重要:網路故障時的自動重試。請求逾時的話,只要是冪等的方法,用戶端或代理伺服器就能自動重送。因為重送 POST 並不安全,瀏覽器才會跳出「要重新送出表單嗎?」的確認對話框。

比較冪等的 GET、PUT、DELETE 與每次送出都建立新付款的 POST /payments,並顯示網路逾時後的自動重試

Cacheable(可快取)

指回應可以被儲存下來,對相同的請求重複使用

為什麼重要:效能。CDN 會把 GET 回應快取在全球各地的節點。POST 的回應一般不會被快取,因為快取機制認定 POST 是會改變狀態的操作,回應可能立刻就過期了。QUERY 和 GET 一樣可快取,但因為快取鍵也包含請求主體,不同的查詢主體會使用不同的快取項目。

在 CDN 上比較可快取的 GET、不可快取的 POST,以及不同請求主體對應到不同快取項目的 QUERY

QUERY 不是 GET 或 POST 的替代品

QUERY 不是既有方法的替換,而是填補至今沒有適當方法可用的情境。

使用情境 適當的方法 理由
以 URL 取得資源 GET 簡單、通用,URL 本身就是資源的識別碼
少量參數的搜尋 GET ?q=shoes&color=red 這種程度用 URL 就夠了
資源的建立、更新、刪除 POST/PUT/DELETE 改變狀態的操作
複雜結構化查詢的搜尋 QUERY 超出 GET 的 URL 限制的搜尋條件

該用 GET 的場合: 查詢塞得進 URL 的時候。簡單的篩選、分頁、關鍵字搜尋。今天大多數的搜尋 API 用 GET 都沒問題。

該用 POST 的場合: 真的要改變狀態的時候。建立記錄、送出表單、觸發動作。

該用 QUERY 的場合: 搜尋條件塞不進 URL 參數的時候。巢狀篩選、地理位置查詢、多個陣列條件、送出結構化查詢語言。在 QUERY 出現以前,這個用途只能挪用 POST,並犧牲可快取性。

GET、POST、QUERY 各自的請求範例與典型使用情境,並提到過去只能挪用 POST 的用途

為什麼到現在才出現

RFC 10008 的共同作者是 Cloudflare 的 James Snell 與 Akamai 的 Mike Bishop。兩大 CDN 公司的工程師寫下這份規格,意味著 CDN 層級的 QUERY 支援有機會較早實現。

多年來,搜尋 API 上「POST /search」這個模式是事實上的標準,但它的語意其實是「建立一個搜尋資源」,與實際狀況脫節。QUERY 方法從根本解決了這個問題。

前提與環境

  • Python 3.12+
  • Starlette 0.46+(ASGI 框架)
  • uvicorn 0.34+
  • httpx 0.28+(用戶端)
  • uv(套件管理工具)

範例程式碼在這個儲存庫:

https://github.com/oharu121/http-query-method-rfc10008-demo

範例的全貌

以商品型錄的搜尋 API 為題材,把同一個搜尋條件用 GET、POST、QUERY 三種方法執行,比較其中的差異。

搜尋條件範例:

{
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
"min_rating": 4.5,
"in_stock": true,
"near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
"sort": {"field": "price", "order": "desc"}
}

各欄位的意義:

欄位 型別 說明
categories string[] 目標分類。以陣列指定多個(OR 條件)
price object 價格區間。以 min/max 指定巢狀範圍
tags string[] 商品標籤。需符合陣列內全部(AND 條件)
min_rating number 最低評分(0〜5)
in_stock boolean 只篩選有庫存的商品
near object 以地理位置做鄰近搜尋。緯度、經度、半徑以巢狀指定
sort object 排序條件。目標欄位與升冪/降冪以巢狀指定

值得注意的是 pricenearsort 都是巢狀物件。想用 GET 的查詢字串表達它們,就必須攤平成 price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0 這樣,結構因此消失。欄位越多 URL 就越長,很快就會碰到實務上的上限(約 2048 個字元)。

巢狀的 QUERY JSON 主體,與同一個條件攤平成 GET 參數後失去欄位間關係的樣子並列,並顯示超過實務上限 2048 個字元的 URL

伺服器實作

專案設定

pyproject.toml
[project]
name = "http-query-demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"starlette>=0.46",
"uvicorn>=0.34",
"httpx>=0.28",
]
終端機視窗
uv sync
uv run uvicorn server:app --reload

QUERY 方法的路由

在 2026 年 6 月的時間點,大多數 Web 框架都沒有原生支援 QUERY 方法。在 Starlette 裡,我是把自訂的方法名稱傳給 Routemethods 參數來處理的。

async def search_dispatcher(request: Request) -> JSONResponse:
"""Dispatch to a handler according to the HTTP method"""
match request.method:
case "GET":
return await search_via_get(request)
case "POST":
return await search_via_post(request)
case "QUERY":
return await search_via_query(request)
case "OPTIONS":
return await product_search_options(request)
case _:
return JSONResponse(
{"error": f"Method {request.method} not allowed"},
status_code=405,
headers={"Allow": "GET, POST, QUERY, OPTIONS"},
)
routes = [
Route(
"/products/search",
search_dispatcher,
methods=["GET", "POST", "QUERY", "OPTIONS"],
),
]
app = Starlette(routes=routes)

重點在於 Starlette 的 Route 會接受 methods 清單裡的任意字串。並不是框架端明確支援了「QUERY」,而是它接受了一個未知的方法名稱。

GET 處理器:扁平查詢字串的極限

async def search_via_get(request: Request) -> JSONResponse:
params = request.query_params
query: dict[str, Any] = {}
if cats := params.get("categories"):
query["categories"] = cats.split(",")
if price_min := params.get("price_min"):
query.setdefault("price", {})["min"] = int(price_min)
if price_max := params.get("price_max"):
query.setdefault("price", {})["max"] = int(price_max)
# ... individual params like near_lat, near_lng, near_radius are needed

為了表達巢狀結構(price.minnear.lat),必須自行定義一套扁平的參數命名慣例(price_minnear_lat)。這需要用戶端與伺服器之間的隱含約定,寫進 OpenAPI 綱要也變得繁瑣。

POST 處理器:會動,但語意是錯的

async def search_via_post(request: Request) -> JSONResponse:
body = await request.body()
content_type = request.headers.get("content-type", "")
if "json" not in content_type:
return JSONResponse(
{"error": "Content-Type must be application/json"},
status_code=415,
)
query = json.loads(body)
data = search_products(query)
return JSONResponse(data)

程式碼很簡單,但問題出在 HTTP 語意。

  • 代理伺服器與 CDN 把 POST 視為「有狀態變更的操作」,不會快取回應
  • 網路故障時的自動重試並不安全(同一個 POST 送兩次,可能讓副作用發生兩次)
  • 瀏覽器上一頁時詢問「要重新送出表單嗎?」,也是 POST 不安全的一種表現

QUERY 處理器:符合 RFC 10008 的實作

async def search_via_query(request: Request) -> JSONResponse:
body = await request.body()
# RFC 10008 §3: a Content-Type header is mandatory
content_type = request.headers.get("content-type", "")
if not content_type:
return JSONResponse(
{"error": "QUERY requests MUST include a Content-Type header (RFC 10008 §3)"},
status_code=400,
)
# RFC 10008 §3: for an unsupported media type, respond 415 + notify supported types with Accept-Query
if "json" not in content_type:
return JSONResponse(
{"error": f"Unsupported media type: {content_type}"},
status_code=415,
headers={"Accept-Query": '"application/json"'},
)
# RFC 10008 §4: a QUERY response is cacheable
# include a hash of the request body in the cache key
cache_key = _cache_key("QUERY", request.url.path, body)
if cached := _get_cached(cache_key):
return JSONResponse(cached, headers={"X-Cache": "HIT"})
try:
query = json.loads(body)
except json.JSONDecodeError as e:
# RFC 10008 §3: syntactically correct but semantically unprocessable → 422
return JSONResponse(
{"error": f"Unprocessable query content: {e}"},
status_code=422,
)
data = search_products(query)
_set_cache(cache_key, data)
return JSONResponse(
data,
headers={
"X-Cache": "MISS",
"Accept-Query": '"application/json"',
},
)

RFC 10008 定義的錯誤處理要點:

情況 狀態碼 說明
沒有 Content-Type 標頭 400 Bad Request QUERY 必須要有 Content-Type
不支援的媒體類型 415 Unsupported Media Type Accept-Query 標頭告知支援的類型
無法解析的主體 422 Unprocessable Content 媒體類型正確,但內容無效

QUERY 請求失敗時分別回傳 400 Bad Request、415 Unsupported Media Type、422 Unprocessable Content 的表格,並附上各自的請求與回應範例

快取的實作

QUERY 最大的好處是可快取性。和 GET 不同,快取鍵不只要包含 URI,還必須包含請求主體

def _cache_key(method: str, path: str, body: bytes) -> str:
body_hash = hashlib.sha256(body).hexdigest()[:16]
return f"{method}:{path}:{body_hash}"

RFC 10008 §4 允許快取把主體中「語意上不重要的差異」正規化。以 JSON 為例,鍵的順序或縮排差異可以忽略。不過,若用戶端指定了 no-transform 快取指令,就不得進行正規化。

驗證能不能動

用 curl 送出 QUERY 請求

終端機視窗
curl -s -D - -X QUERY "http://localhost:8000/products/search" \
-H "Content-Type: application/json" \
-d '{
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
"min_rating": 4.5,
"in_stock": true,
"near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
"sort": {"field": "price", "order": "desc"}
}'

因為 curl 可以用 -X QUERY 指定任意 HTTP 方法,直接就能用。

第一次的回應(快取 MISS)

HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: MISS
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d
accept-query: "application/json"
x-note: Safe + idempotent + cacheable + structured body (RFC 10008)
{"total":1,"offset":0,"limit":10,"results":[{"id":4,"name":"iPhone 16 Pro",...}]}

第二次的回應(快取 HIT)

重送同一個請求:

HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: HIT
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d

以相同的快取鍵命中了。這種快取行為,依規格來說是 POST 做不到的。

Python 用戶端(httpx)

import httpx
import json
SEARCH_QUERY = {
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
}
with httpx.Client(base_url="http://localhost:8000") as client:
# httpx supports a custom HTTP method via the request() method
resp = client.request(
"QUERY",
"/products/search",
content=json.dumps(SEARCH_QUERY),
headers={"Content-Type": "application/json"},
)
print(resp.json())

因為 httpx 的 client.request() 第一個引數接受任意 HTTP 方法名稱,不用特別處理就能送出 QUERY 請求。

把 GET 的 URL 長度問題視覺化

從 Python 用戶端的執行結果,來看 GET 的 URL 長度:

GET /products/search?... (flat query string)
→ URL length: 212 chars

即使是這麼簡單的搜尋條件,也有 212 個字元。實務上搜尋條件可能達到 20〜30 項,很快就會碰到 URL 的實務上限(約 2048 個字元)。

確認錯誤處理

No Content-Type → 400: QUERY requests MUST include a Content-Type header (RFC 10008 §3)
Wrong Content-Type → 415: Unsupported media type: text/plain
Accept-Query header: "application/json"
Malformed JSON → 422: Unprocessable query content: ...

多虧了 Accept-Query 標頭,用戶端可以自動得知「這個端點接受哪些媒體類型的 QUERY」。

QUERY 方法的重要規格要點

Accept-Query 標頭

伺服器可以在回應標頭回傳 Accept-Query,告知它在 QUERY 上支援的媒體類型。

Accept-Query: "application/json", application/sql;charset="UTF-8"

這會成為未來支援 JSON 以外查詢語言(類 SQL、JSONPath 等)的內容協商基礎。

重新導向的行為

QUERY 的重新導向和 POST 不同:

狀態碼 行為
301/308(永久) 對新的 URI 重送 QUERY
302/307(暫時) 對新的 URI 重送 QUERY
303(See Other) 對新的 URI 送出 GET

POST 曾有在 301/302 時方法被改成 GET 的曖昧行為,但 QUERY 定義得很明確。

比較 301/308、302/307、303 上 QUERY 明確定義的重新導向行為,與 POST 在歷史上曖昧的行為的表格

對 CORS 的影響

因為 QUERY 不在 CORS 安全清單上,從瀏覽器送出時需要預檢請求(OPTIONS)。

Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS

這可能影響瀏覽器用戶端的效能(會多出一次來回)。

瀏覽器送出 OPTIONS 預檢並收到 204 No Content,接著送出實際的 QUERY 請求並取得 200 OK 的四步驟往來

與 GraphQL 的關係:互補而非競爭

你可能會想:「QUERY 方法是不是想解決和 GraphQL 一樣的問題?」簡單說,兩者是互補而非競爭。它們運作在不同的層。

GraphQL HTTP QUERY
它是什麼 查詢語言+執行環境 一種傳輸方法
應用層(如何表達查詢) 協定層(如何送出查詢)
它定義什麼 綱要、型別、解析器、欄位選擇 一個請求的安全性、冪等性、可快取性

GraphQL 定義要查詢什麼,HTTP QUERY 定義如何在 HTTP 上送出那個查詢。

把應用層的查詢語言 GraphQL 與協定層的傳輸方法 HTTP QUERY 對照的表格,以及用 QUERY /graphql 送出 GraphQL 查詢的流程

GraphQL 目前的傳輸問題

今天的 GraphQL 主要以 POST 送出查詢:

終端機視窗
# the common way GraphQL is sent today
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'

於是 GraphQL 直接繼承了 POST 的問題:

  • CDN 不會快取回應(POST 被視為狀態變更)
  • 網路故障時沒有自動重試
  • HTTP 層沒有安全性保證

也有一些 GraphQL 實作使用 GET(把查詢放進 URL),但複雜的 GraphQL 查詢很快就會碰到 URL 長度上限。

QUERY 可以改善 GraphQL 的傳輸

把 GraphQL 的讀取查詢用 QUERY 方法送出,兩邊的好處都拿得到:

終端機視窗
# GraphQL over HTTP QUERY — the ideal combination
curl -X QUERY https://api.example.com/graphql \
-H "Content-Type: application/graphql+json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'

CDN 可以判定「這是安全、冪等、可快取的」。GraphQL 的 mutation(資料變更)維持 POST 即可,因為 mutation 確實會改變狀態,POST 的語意才是正確的。

真正的競爭軸線

市場不需要在 GraphQL 和 QUERY 之間二選一。真正在競爭的是 API 設計哲學的層次:

比較 競爭?
GraphQL vs REST Yes:API 設計取向的差異
HTTP QUERY vs 挪用 POST 做搜尋 Yes:QUERY 取代 POST 的權宜做法
GraphQL vs HTTP QUERY No:層不同,可以組合

反過來說,QUERY 對 GraphQL 團隊是好消息。因為不必改動 GraphQL 本身,HTTP 傳輸層的快取問題就有機會解決。

同樣的互補關係,也適用於 JSON-RPC、gRPC-Web 等其他以 POST 送出請求的協定。

框架支援狀況(2026 年 6 月時點)

框架 QUERY 支援 備註
Starlette 可作為自訂方法使用 methods=["QUERY"] 就能運作
Express.js 沒有實作 app.query() app.all() +手動分派處理
FastAPI 透過 Starlette 可行 沒有原生的裝飾器
Spring Boot 需要自訂註解 @RequestMapping(method="QUERY")
Ruby on Rails 討論中 論壇上有提案

除非框架、反向代理伺服器、API 閘道、CDN、WAF 全都支援,否則正式環境很難用上。不過,規格的共同作者是 Cloudflare 與 Akamai 的工程師,這意味著 CDN 層級的支援可能會早一步到來。

總結

RFC 10008 的 HTTP QUERY 方法,是對「搜尋 API 只能用 POST」這個長年權宜做法的正式解答。

QUERY 解決了什麼:

  • 送出 GET 做不到的結構化請求主體
  • 取回 POST 失去的安全性、冪等性、可快取性
  • 透過 Accept-Query 標頭做明確的內容協商

目前的限制:

  • 框架的原生支援幾乎不存在(雖然不少框架能當成自訂方法處理)
  • CDN、代理伺服器、WAF 的支援還在後頭
  • 需要 CORS 預檢(對瀏覽器用戶端的 API 而言)

現在還不是投入正式環境的階段,但理解規格並先做準備是值得的。特別是在設計有複雜搜尋條件的 API 時,把「日後容易遷移到 QUERY 的設計」放在心上是有價值的。

參考連結

分享這篇文章