Emmanuel Corels
← Blog
Networking & ISP Systems

Replace an HTTP GET body before a proxy drops it

Trace the request through every hop, understand current GET semantics, redesign simple retrieval as URI parameters and move complex criteria to an explicit POST operation.

By Emmanuel Corels

An API works when called directly in one library but returns empty results through a gateway. The client sends GET /search with JSON criteria in the request body. The origin reads it; an intermediary might reject it, omit it from a cache key, or forward a request whose body no longer has the meaning the application expects.

Capture what every hop actually receives

curl --http1.1 -sv   -X GET 'https://api.example.test/v1/search'   -H 'Content-Type: application/json'   --data-binary '{"query":"router","limit":10}'

Record the client request, edge access log, reverse-proxy trace and origin request under one correlation ID. Compare method, target URI, Content-Length or transfer coding, and bytes received. Avoid logging sensitive body content; a byte count and cryptographic digest can establish whether the same payload crossed each boundary.

Use the current HTTP rule, not folklore

RFC 9110 states that content in a GET request has no generally defined semantics, cannot alter the meaning or target of the request, and can trigger rejection because of request-smuggling risk. A client should not generate it unless directly communicating with an origin that has explicitly agreed to support it. Even that private agreement is fragile when intermediaries are present.

This is subtler than “HTTP forbids a GET body.” Message framing can carry content, but interoperable GET semantics do not give that content a general role in selecting the representation.

Move simple retrieval criteria into the target URI

curl -G 'https://api.example.test/v1/search'   --data-urlencode 'query=router'   --data-urlencode 'status=active'   --data-urlencode 'limit=10'   --data-urlencode 'cursor=eyJpZCI6MTIzNH0'

Define repeated parameters, empty values, ordering, Unicode normalization and encoding in the API contract. Do not concatenate raw strings:

from urllib.parse import urlencode

params = {
    "query": "router & switch",
    "status": ["active", "degraded"],
    "limit": 10,
}
url = "https://api.example.test/v1/search?" + urlencode(
    params, doseq=True
)

The URI now identifies the selected result. Caches, observability tools and signatures can reason about it consistently. Remember that URLs commonly appear in access logs, browser history and analytics; never place secrets in query parameters.

Use POST for large or structured search criteria

curl -sv 'https://api.example.test/v1/searches'   -H 'Content-Type: application/json'   -H 'Idempotency-Key: 6e278d6d-6df4-4d0f-b6c6-1b239f0ca613'   --data-binary '{
    "terms": [{"field":"site","operator":"in","values":["lagos","abuja"]}],
    "sort": [{"field":"latency_ms","direction":"asc"}],
    "limit": 100
  }'

POST has defined semantics for content to be processed by the target resource. It does not automatically mean “create a database row.” The endpoint can synchronously return results, or create a search job and return 201 Created or 202 Accepted with a result URL.

Make retries explicit

GET is safe and idempotent by specification; infrastructure often retries it. A POST search may be read-only at the application layer, but generic clients do not know that. Document retry behavior, apply a deadline, and use an idempotency key if the endpoint creates server-side jobs or bills work.

Repair cache behavior during the migration

HTTP/1.1 200 OK
Cache-Control: private, max-age=30
Vary: Accept-Encoding, Authorization
Content-Type: application/json

For GET, ensure every representation-changing input is present in the URI or an appropriate request field included through Vary. Never rely on a cache including a GET body in its cache key. For POST, assume responses are not reusable by generic caches unless the explicit HTTP caching requirements are satisfied and tested.

Audit signatures and authentication

Custom request-signing schemes sometimes hash a GET body even though a proxy drops it. The origin then verifies different bytes or, worse, authorizes criteria it never received. Define a canonical target URI, selected headers and content digest for methods where content is meaningful. Test the signature at the public edge, not only against the application server.

Test the whole route matrix

# Public edge
curl -sv 'https://api.example.test/v1/search?query=router'

# Internal gateway, from an authorized diagnostic host
curl -sv 'https://gateway.internal/v1/search?query=router'

# Origin with correct Host routing
curl -sv 'http://10.0.0.25:8080/v1/search?query=router'   -H 'Host: api.example.test'
SymptomLikely boundaryEvidence
Client library refuses GET bodyClient API policyWire capture shows no request sent
Edge returns 400/411Gateway framing/method policyEdge log and response before origin
Origin receives zero bytesIntermediary stripped or never forwarded contentPer-hop byte count/digest
Different bodies share cached resultCache key ignores undefined contentAge/Via headers and repeat test
Direct call works, public path failsPrivate origin behavior is not interoperableHop-by-hop comparison

Roll out without breaking clients

Add the query-parameter or POST contract first, publish examples and observability, migrate clients, then reject the legacy form with a clear 400 response. Do not silently ignore the old body because that can return a dangerously broader result than requested.

If request content changes what a GET means, the API has hidden part of the resource identifier in a place the HTTP ecosystem is not required to understand.

Sources: the Stack Overflow semantics discussion (question by Evert; accepted answer by Paul Morgan, CC BY-SA) and RFC 9110, GET.

Primary source: Review the official reference ↗