When Go's HTTP/2 Client Trips a PING Flood Defense
In September 2025, an internal engineering chat thread asked a pointed question: which component of our stack was sending ErrCode=ENHANCE_YOUR_CALM to an HTTP/2 client? Two internal microservices were failing to communicate, and the cause was not immediately obvious. The investigation led to an easy-to-make mistake in Go's standard library that caused a client to emit PING flood-like traffic, and it ended with a simple fix.

Well-Known Attacks and Defensive Closures
HTTP/2's binary frame format introduces control frames—SETTINGS, WINDOW_UPDATE, RST_STREAM, and GOAWAY—that manage streams and connection state. These features are powerful, but the specification explicitly warns that implementations must monitor and limit their use to avoid denial-of-service risks.

Cloudflare has implemented a range of HTTP/2 defenses over the years, including mitigations for the 2019 Netflix vulnerabilities and the 2023 Rapid Reset attack. When we detect behavior we deem malicious—such as a PING flood, as documented in CVE-2019-9512—we close the connection with a GOAWAY frame carrying the ENHANCE_YOUR_CALM error code.
The PING flood attack works because each incoming PING frame must be answered with an acknowledgment, forcing the peer to do work. Legitimate uses of PING include liveness checks and layer 7 round-trip time measurement. However, too many PINGs in a short window—even from well-meaning clients—can trigger our CVE-2019-9512 defenses. We've seen this before: shortly after launching gRPC support in 2020, some gRPC clients caused interop issues by sending frequent PINGs as part of a window-tuning optimization, and the Rust Hyper crate's Adaptive Window feature had a similar problem until it was fixed.
Two Internal Services, an Edge Connection, and a Mystery
When the internal report came in, we confirmed from our logs that the client was hitting our PING flood mitigation. But the question remained: why would an internal client behave this way?
In this case, the microservices were intentionally communicating over the Cloudflare edge. This dogfooding approach gives us the advantage of testing our own infrastructure, allows the use of Cloudflare Access for authentication, and lets services written with Cloudflare Workers easily reach other services at the edge.
The client's configuration suggested it shouldn't need to PING very often:
t2.PingTimeout = 2 * time.Second
t2.ReadIdleTimeout = 5 * time.Second
We built a minimal reproduction of the client using GODEBUG=http2debug=2 for detailed trace logging. Combining group log analysis with reading Go's standard library source, one engineer noticed something odd:
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote RST_STREAM stream=9 len=4 ErrCode=CANCEL
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote PING len=8 ping="j\xe7\xd6R\xdaw\xf8+"
every ping seems to be preceded by a RST_STREAM
The association with Rapid Reset was a red herring; our logs clearly showed ENHANCE_YOUR_CALM being triggered by a PING flood. A search led us to a gRPC mailing list thread noting that sending a PING along with an RST_STREAM lets a client distinguish between an unresponsive server and a slow response. That explained the PINGs but raised a new question: why so many stream resets?
Logs revealed the server had already sent a DATA frame with the END_STREAM flag set. Per the HTTP/2 stream state machine, the stream should have transitioned to closed at that point. The client had no reason to send RST_STREAM—yet it did, followed by a PING.
2025/09/02 17:33:18 http2: Transport received DATA flags=END_STREAM stream=47 len=0 data=""
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote RST_STREAM stream=47 len=4 ErrCode=CANCEL
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote PING len=8 ping="\x97W\x02\xfa>\xa8\xabi"
The breakthrough came when an engineer noticed the pattern: the reset-and-ping sequence only happened when the client called resp.Body.Close(). Go's HTTP library does not automatically read the response body; it keeps the stream open for the caller. In our example, there was no body to read at all—the data frame was empty. When we changed the client to read the (absent) body with io.Copy(io.Discard, resp.Body) before closing it, both the unnecessary RST_STREAM and the associated PING disappeared.
Updating the production client with the same fix eliminated all ENHANCE_YOUR_CALM closures within hours.
Why Reading Bodies in Go Can Be Unintuitive
Ensuring a response body is always fully read is not always straightforward in Go. Consider this pattern:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return err
}
This looks like it reads the entire body, but it does not. json.Decoder stops reading as soon as it finds a complete JSON document or encounters an error. If the response contains multiple JSON documents or invalid trailing data, the rest of the body is never read.
To guarantee full consumption of response bodies, we've replaced defer response.Body.Close() with a pattern that explicitly drains the body before closing:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return err
}
What to Do If You See ENHANCE_YOUR_CALM
HTTP/2 implementations harden themselves against feature misuse by closing connections, often with ENHANCE_YOUR_CALM. If your client triggers such a closure, start by establishing ground truth: grab a packet capture with TLS decryption keys via SSLKEYLOGFILE or enable detailed trace logging. Look for frequent repeated frames that resemble attack patterns.
If you're writing Go clients, always read HTTP/2 response bodies—even empty ones—to avoid sending unnecessary RST_STREAM and PING frames. This matters most when reusing a single connection for multiple requests, where the accumulated unnecessary frames can trip defensive mitigations.



