The Symptom
During regression testing on Stage environment, we hit persistent HTTP 400 errors:
- Service A calls Service B via Feign, returns 400
- Same Docker image works fine in test environment
- After restarting the Stage node, it might work — or might not
- Once a node starts failing, all requests fail; once it works, it keeps working
This “behavior changes after restart” pattern pointed to class loading order or static initialization issues.
A Bug That Hides
This wasn’t our first encounter with this 400 error.
First time: The error appeared, we switched Feign’s HTTP client to OkHttp, restarted, problem gone. Case closed — or so we thought.
Later: A developer switched back to the default client for some reason. No 400 errors appeared (class loading order happened to be “safe”), so no one followed up.
This time: The error resurfaced after a deployment. This time we preserved the environment and dug in.
Lessons:
- “Fixed after restart” ≠ actually fixed
- “Can’t reproduce” ≠ bug doesn’t exist
- Class loading order bugs can hide for months, then suddenly strike
Packet Capture: The Smoking Gun
Using tcpdump to capture the failing request:
|
|
Found duplicate Content-Length headers:
|
|
Verifying the Behavior
To confirm Tomcat rejects duplicate Content-Length, we crafted a raw HTTP request using netcat:
|
|
Response:
|
|
This confirms the server correctly rejects the request per RFC 7230 Section 3.3.2:
If a message is received that has multiple Content-Length header fields… the recipient MUST either reject the message as invalid or replace the duplicated field-values with a single valid Content-Length field.
Tomcat chooses to reject.
Tracing to Feign
Duplicate headers must be added somewhere between our application code and the TCP layer. Since we’re using Feign with the default HttpURLConnection-based client, we examined feign.Client.Default and found the culprit in the convertAndSend method.
The Bug: Feign Adds Content-Length Twice
Located the issue in feign.Client.Default (Feign 13.1):
|
|
Execution flow when field = "Content-Length":
1. First if: field.equals(CONTENT_LENGTH) → true
→ addRequestProperty("Content-Length", "156") // Added once
2. Second if: field.equals(ACCEPT_ENCODING) → false
→ falls through to else branch
→ addRequestProperty("Content-Length", "156") // Added again!
The second if should be else if. This causes Content-Length to be added twice.
Fixed in Feign 13.3 (changed to else if).
If this bug exists in the code, why doesn’t it trigger every time?
The Mask: JDK’s Protection Mechanism
HttpURLConnection has a restricted headers mechanism that filters out certain “dangerous” headers.
From JDK source sun.net.www.protocol.http.HttpURLConnection:
|
|
The addRequestProperty implementation:
|
|
Key insight:
- When
allowRestrictedHeaders = false(default),Content-Lengthis restricted - Feign’s two
addRequestPropertycalls are silently ignored - JDK calculates the actual content length from the request body and sets the header automatically in
writeRequests() - The Feign bug is masked by JDK’s protection
So why does the protection sometimes fail?
The Trigger: static final and Class Loading Order
Notice allowRestrictedHeaders is static final:
|
|
Static final variables are initialized at class load time and never change afterward.
This means:
- If
HttpURLConnectionloads when the property is unset orfalse→allowRestrictedHeaders = false→ protection ON - If someone sets the property to
truebeforeHttpURLConnectionloads →allowRestrictedHeaders = true→ protection OFF - If the property is set after
HttpURLConnectionloads → too late, static final won’t change
The question becomes: Who sets this property? When?
Tracing the Property Source
Using a Property Hook to intercept System.setProperty calls:
|
|
Call PropertyTracer.install() at the earliest point in application startup. Caught this stack trace:
[TRACE] setProperty: sun.net.http.allowRestrictedHeaders = true
java.lang.Exception: Stack trace
at PropertyTracer$1.setProperty(PropertyTracer.java:15)
at cn.hutool.http.GlobalHeaders.putDefault(GlobalHeaders.java:44)
at cn.hutool.http.GlobalHeaders.<clinit>(GlobalHeaders.java:26)
at cn.hutool.http.HttpRequest.<clinit>(HttpRequest.java:56)
...
The source: Hutool’s GlobalHeaders class:
|
|
Hutool’s intent is legitimate—it enables setting custom Host headers for HTTP clients. But disabling JDK’s protection has unintended side effects when combined with other buggy libraries.
The Complete Causal Chain
JVM Startup
│
▼
Class Loading Order
(uncertain)
/ \
/ \
▼ ▼
┌──────────┐ ┌──────────┐
│ Hutool │ │ JDK │
│ first │ │ first │
└──────────┘ └──────────┘
│ │
▼ ▼
GlobalHeaders HttpURLConnection
sets property loads, reads
= true = false
│ │
▼ ▼
HttpURLCon. Hutool sets
reads = true (too late)
│ │
▼ ▼
Protection Protection
OFF ON
│ │
▼ ▼
Feign bug Feign bug
exposed masked
│ │
▼ ▼
400 Error Success
The behavior pattern:
- Within a single JVM lifecycle, behavior is deterministic (always fails or always works)
- Across different JVM starts, behavior may differ (depends on class loading order)
- Across different environments, behavior may differ (depends on initialization paths)
Why Does Class Loading Order Vary?
Class loading is triggered by first use. The order depends on which code path executes first during startup, thread scheduling in multi-threaded initialization, Spring bean initialization order, and whether dependencies are loaded lazily or eagerly.
Even the same code can have different initialization orders across JVM restarts. A small change in bean dependencies or startup timing can flip the order.
Why Does JDK Restrict These Headers?
This stems from CVE-2010-3573, a security vulnerability from 2010. Malicious Applets could set arbitrary headers (like Host) via HttpURLConnection, bypassing same-origin policy protections. JDK 6u22 introduced the restricted headers mechanism, prohibiting user code from setting these sensitive headers by default.
Content-Length is restricted because a mismatch between the declared length and actual body size can cause request smuggling attacks or confuse downstream proxies.
The Fix
Root cause fix: Upgrade Feign to 13.3 or later, which fixes the if/else if issue.
|
|
Alternative options:
- If you can’t upgrade Feign immediately, switch to
OkHttpClientorApacheHttpClient— they bypassHttpURLConnectionentirely - Consider whether Hutool’s modification of JDK security defaults is acceptable for your security posture
Debugging Takeaways
- “Fixed after restart” is not the end. Preserve the scene, find the root cause.
- For HTTP layer issues, tcpdump is your friend.
- Multi-layer problems require peeling back layers: Feign, JDK, system property, third-party lib.
static finalvalues are set at class load time and immutable afterward. If behavior flips across restarts, suspect initialization order.- When you need to trace a system property’s origin, a Property Hook (
System.setPropertieswith a tracing wrapper) is an effective technique.
References: