Quick answer: A 400 that only appears when a query value contains a space or a special character is usually double percent-encoding. The client encodes once, then a helper or proxy encodes the already-encoded string again, turning a space into %2520 instead of %20. The server decodes once and receives the literal text %20, which fails validation or matches nothing. The fix is to encode exactly once at the boundary that builds the URL and pass raw values through every layer above it.
The bug is easy to miss because the happy path uses simple values. A search term like "report" survives, while "sales report" becomes a request the API rejects. Logs show a URL full of %25, the telltale sign of an encoded percent sign. The reasoning that uncovers it is simple: % itself encodes to %25, so a correct single encoding of a space never contains a percent. Seeing %2520 means the string was encoded twice.
Where the second encoding happens
Double encoding almost always comes from two layers each assuming the other did not encode. Knowing the common spots narrows the search.
| Layer | What it does | Failure when duplicated |
|---|---|---|
| Template string | Manually encodeURIComponent | Then the client encodes again |
| HTTP client params | Encodes query object | Value pre-encoded by caller |
| Gateway/proxy | Rewrites and re-encodes | Encodes an already encoded URL |
| Test helper | Builds full URL | Encodes a pre-built query string |
Encode once, at the boundary
Most HTTP clients accept a params object and handle encoding, so the application should pass raw values and let that single boundary encode. Manually encoding and then concatenating into a URL string is what creates the second pass.
// correct: raw values in a params object; the client encodes exactly once const res = await client.get("/v1/search", { params: { q: "sales report", region: "us-west" }, }); // request line: /v1/search?q=sales%20report®ion=us-west // wrong: pre-encoding a value the client will encode again const bad = encodeURIComponent("sales report"); // "sales%20" await client.get("/v1/search", { params: { q: bad } }); // request line: /v1/search?q=sales%2520report
Reproduce and pin it with a test
A focused test that sends a value requiring encoding proves the wire request is correct and prevents a regression. Assert the exact request line rather than only the response, so a second encoding layer is caught immediately.
it("encodes a space exactly once in the query string", async () => { const request = await captureRequest(() => client.get("/v1/search", { params: { q: "sales report" } }) ); expect(request.url).toContain("q=sales%20report"); expect(request.url).not.toContain("%25"); });
Check the full path, including the proxy
If the application code is clean but the bug persists, inspect the gateway. A rewrite rule that re-encodes the query string can double-encode even a correctly built client request. Tracing the URL at each hop, from the client to the server, identifies the layer that adds the second percent. The durable rule is one encoding boundary, raw values everywhere else, and a test that asserts the literal wire format.
Frequently asked questions
Why does the same request work in curl but fail in the app?
curl sends the URL exactly as written, so a correctly encoded curl string works. The app may encode a second time through its client or a helper. Compare the literal request lines to see the extra encoding.
Should I just decode twice on the server?
No. Double decoding would accept malformed double-encoded requests while masking the client bug, and it can corrupt values that legitimately contain percent signs. Fix the client to encode once.
What about reserved characters like & and =?
They must be encoded when they appear as data, not syntax. A params object handles this correctly; manual string building is where reserved characters and double encoding both tend to break.
How do I see the real request line?
Enable client request logging or use a capturing proxy, and inspect the raw bytes before the server decodes. The presence of %25 where you expect a single encoded character confirms the double encoding immediately.
This is part of the API Debugging Guide — start there for the full picture of tracing requests across layers.
Top comments (0)