
用 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
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 操作被觸發,使用者的資料因此被刪除。「安全」這個宣告,是整個基礎設施自動化不可或缺的訊號。

Idempotent(冪等)
指同一個請求送 1 次和送 100 次結果都相同。GET、PUT、DELETE 是冪等的。POST 不是(付款送兩次,就可能被扣兩次款)。
為什麼重要:網路故障時的自動重試。請求逾時的話,只要是冪等的方法,用戶端或代理伺服器就能自動重送。因為重送 POST 並不安全,瀏覽器才會跳出「要重新送出表單嗎?」的確認對話框。

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

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,並犧牲可快取性。

為什麼到現在才出現
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 | 排序條件。目標欄位與升冪/降冪以巢狀指定 |
值得注意的是 price、near、sort 都是巢狀物件。想用 GET 的查詢字串表達它們,就必須攤平成 price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0 這樣,結構因此消失。欄位越多 URL 就越長,很快就會碰到實務上的上限(約 2048 個字元)。

伺服器實作
專案設定
[project]name = "http-query-demo"version = "0.1.0"requires-python = ">=3.12"dependencies = [ "starlette>=0.46", "uvicorn>=0.34", "httpx>=0.28",]uv syncuv run uvicorn server:app --reloadQUERY 方法的路由
在 2026 年 6 月的時間點,大多數 Web 框架都沒有原生支援 QUERY 方法。在 Starlette 裡,我是把自訂的方法名稱傳給 Route 的 methods 參數來處理的。
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.min、near.lat),必須自行定義一套扁平的參數命名慣例(price_min、near_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 最大的好處是可快取性。和 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 OKx-search-method: QUERYx-cache: MISSx-cache-key: QUERY:/products/search:7f73fb16e7395e7daccept-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 OKx-search-method: QUERYx-cache: HITx-cache-key: QUERY:/products/search:7f73fb16e7395e7d以相同的快取鍵命中了。這種快取行為,依規格來說是 POST 做不到的。
Python 用戶端(httpx)
import httpximport 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 定義得很明確。

對 CORS 的影響
因為 QUERY 不在 CORS 安全清單上,從瀏覽器送出時需要預檢請求(OPTIONS)。
Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS這可能影響瀏覽器用戶端的效能(會多出一次來回)。

與 GraphQL 的關係:互補而非競爭
你可能會想:「QUERY 方法是不是想解決和 GraphQL 一樣的問題?」簡單說,兩者是互補而非競爭。它們運作在不同的層。
| GraphQL | HTTP QUERY | |
|---|---|---|
| 它是什麼 | 查詢語言+執行環境 | 一種傳輸方法 |
| 層 | 應用層(如何表達查詢) | 協定層(如何送出查詢) |
| 它定義什麼 | 綱要、型別、解析器、欄位選擇 | 一個請求的安全性、冪等性、可快取性 |
GraphQL 定義要查詢什麼,HTTP QUERY 定義如何在 HTTP 上送出那個查詢。

GraphQL 目前的傳輸問題
今天的 GraphQL 主要以 POST 送出查詢:
# the common way GraphQL is sent todaycurl -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 combinationcurl -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 的設計」放在心上是有價值的。