A 200 with an empty body is not a server problem until you have proven it is. The status code tells you the origin answered. It says nothing about whether the bytes survived WordPress’s argument handling, the Requests decompression step, a filter on http_response, or your own retrieval code. Each of those layers can independently hand you an empty or unreadable body while leaving the 200 intact.
This is a walkthrough of that pipeline, in the order the bytes travel, with an observation point at each stage. The failure shape is specific: wp_remote_get() returns an array, wp_remote_retrieve_response_code() says 200, the headers include Content-Encoding: gzip, and wp_remote_retrieve_body() returns '' — or returns bytes that are not the text you expected.
Symptom: the response array is not the body
wp_remote_get() performs an HTTP GET and returns an array or a WP_Error. The documented way to read the body is wp_remote_retrieve_body(). That function is deliberately defensive: it returns an empty string if the response is a WP_Error or if the body key is not set. Its source is four lines long, and the return docblock says “Empty string if no body or incorrect parameter given.”
So the first distinction to make is not “is the body empty” but “is there a body key at all.” Those are different failures with different causes, and wp_remote_retrieve_body() collapses them into the same ''.
$response = wp_remote_get( 'https://example.com/feed.json' );
if ( is_wp_error( $response ) ) {
error_log( 'WP_Error: ' . $response->get_error_message() );
return;
}
error_log( sprintf(
'code=%s body_key=%s body_len=%d encoding=%s',
wp_remote_retrieve_response_code( $response ),
array_key_exists( 'body', $response ) ? 'yes' : 'no',
strlen( (string) ( $response['body'] ?? '' ) ),
(string) wp_remote_retrieve_header( $response, 'content-encoding' )
) );
If body_key=no, you are not looking at a compression problem. Something replaced the response array — a filter, a short-circuit, or a transport that returned a shape you did not expect. If body_key=yes and body_len=0, the transport delivered zero bytes and the question moves upstream. If body_len is nonzero but the content is garbage, you are in the decompression layer, which is the interesting case for a Content-Encoding: gzip header.
Trace: where the bytes actually go
The call path is short and worth holding in your head. wp_remote_get() grabs the shared WP_Http instance and calls its get() method, which routes into WP_Http::request(). That method merges defaults, applies http_request_args, checks pre_http_request for a short-circuit, and then hands the request to the bundled Requests library via WpOrg\Requests\Requests::request(). The result is wrapped in a WP_HTTP_Requests_Response and converted to the array you receive.
Two defaults in that chain matter here. First, decompress defaults to true in the WP_Http::request() argument list. Second, the response array is documented to carry headers, body, response (code and message), cookies, and filename, plus the original http_response object. If any of those are missing when you inspect the array, a filter or a short-circuit is the suspect, not the remote server.
The decompression itself lives in Requests::decompress(). Read its source rather than its reputation. It does the following, in order:
- Rejects non-string input.
- Returns the input unchanged if
trim( $data ) === ''— an empty body is not decompressed, it is passed through. - Reads the first two bytes and looks them up in a magic-header table. If they do not match a known compression signature, it returns the data unchanged, with the source comment “Not actually compressed. Probably cURL ruining this for us.”
- Tries
gzdecode(), thengzinflate(), then a compatibility inflate, thengzuncompress(). - If every attempt fails, returns the original
$data.
That last line is the one to underline. Requests::decompress() fails open. It does not throw when it cannot decode. It hands you back the bytes it received. A decompression mismatch therefore does not surface as an error; it surfaces as a body that is present, nonzero, and unreadable — or, if the transport already stripped the payload, as an empty string that looks like a server problem.
The gzip format itself is unambiguous about what a valid stream starts with: RFC 1952 fixes the identification bytes at ID1 = 31 (0x1f) and ID2 = 139 (0x8b). If the first two bytes of the body are not those, Requests::decompress() will not attempt gzip decoding at all, regardless of what the Content-Encoding header claims. That is the seam where a header and a payload can disagree.
Root cause: the header is a claim, not a guarantee
RFC 9110 defines Content-Encoding as representation metadata describing how the content is intended to be interpreted by the recipient. It is a statement about the payload, not a proof of it. A response can carry Content-Encoding: gzip while the body is already decoded — because an intermediary decoded it and forwarded the header, because the origin misconfigured its compression module, or because the transport negotiated encoding and then handed WordPress a body that no longer matches the header.
When that happens, Requests::decompress() sees a body whose first two bytes are not 0x1f 0x8b, returns it unchanged, and WordPress passes it to your code. Your code, trusting the header, tries to gzdecode() it, fails, and reports an empty result. The 200 is real. The header is real. The body is real. The mismatch is between the header and the bytes.
The other root cause is the inverse: the body is genuinely gzip-encoded, but something in the pipeline removed it before decompression. The two candidates worth checking are stream and limit_response_size. If stream is true, the body is written to a file and the response array’s body is not where you think it is. If limit_response_size is set, the transport truncates the body at that byte count, and a truncated gzip stream fails every decode attempt in Requests::decompress(), which then returns the truncated bytes unchanged. You get a nonzero, unreadable body and a 200.
Neither of these is a core defect. They are argument interactions, and they are visible if you look at the right layer.
Fix: observe at http_response before changing anything
The http_response filter fires on a successful HTTP API response immediately before the response is returned. It receives the response array, the parsed arguments, and the URL. That is the highest-value single observation point for this class of bug, because it sits after transport and after decompression but before your calling code. Whatever you log there is what your code would have received.
add_filter( 'http_response', function ( $response, $args, $url ) {
if ( false === strpos( $url, 'example.com' ) ) {
return $response;
}
$body = isset( $response['body'] ) ? (string) $response['body'] : '';
$head = substr( $body, 0, 2 );
error_log( sprintf(
'http_response url=%s code=%s len=%d magic=%s encoding=%s decompress=%s',
$url,
$response['response']['code'] ?? 'none',
strlen( $body ),
bin2hex( $head ),
$response['headers']['content-encoding'] ?? 'none',
! empty( $args['decompress'] ) ? 'true' : 'false'
) );
return $response;
}, 10, 3 );
Read the log line as a decision table:
len=0andencoding=gzip: the transport delivered nothing. The remote or an intermediary is the suspect. Compare against a direct fetch (below).len>0,magic=1f8b, and your code still gets garbage: decompression did not run or did not succeed. Checkdecompress=falsein the args, and check whether a later filter replaced the body.len>0,magicis something else, andencoding=gzip: the header and payload disagree. The body is already decoded or is not gzip at all. Do not callgzdecode()on it.len>0,magic=1f8b, anddecompress=true: the body reached the filter still compressed. That means decompression was skipped or failed silently. This is the case where you readRequests::decompress()against the actual bytes.
Note that the filter can also be the cause. The documented example for http_response replaces the body for non-200/301/302 codes. Any plugin on the site can do the same for a 200. If your log line shows a body length that does not match what the remote sends, search the codebase for add_filter( 'http_response' before you touch request arguments.
Verification: compare the transport against the WP_Http path
Once you have an observation at http_response, the next step is to establish what the remote actually sends, independent of WordPress. Use a direct transport fetch with the same URL and the same headers, and compare the raw bytes.
curl -sS -D - -o /tmp/body.bin \
-H 'Accept-Encoding: gzip' \
'https://example.com/feed.json'
printf 'first bytes: '; xxd -l 2 /tmp/body.bin
printf 'size: '; wc -c < /tmp/body.bin
If xxd shows 1f8b, the remote is sending gzip and the header is honest. If it shows anything else while the response headers include Content-Encoding: gzip, you have found the mismatch at the origin or an intermediary, and no WordPress argument will fix it. If the file is zero bytes, the remote is sending an empty payload with a 200, and the problem is upstream of WordPress entirely.
To confirm the WordPress side is not the cause, run the same request through wp_remote_get() from WP-CLI with the http_response logger active, and compare the logged len and magic against the curl output. If they match, WordPress is faithfully delivering what the remote sent, and the bug is in your caller’s decoding assumptions. If they differ, the difference is in the argument handling or a filter, and you now have the layer isolated.
wp eval '
$r = wp_remote_get( "https://example.com/feed.json", array( "timeout" => 15 ) );
if ( is_wp_error( $r ) ) { echo $r->get_error_message(); exit; }
printf( "code=%s len=%d magic=%s\n",
wp_remote_retrieve_response_code( $r ),
strlen( wp_remote_retrieve_body( $r ) ),
bin2hex( substr( wp_remote_retrieve_body( $r ), 0, 2 ) )
);
'
If the WP-CLI run and the curl run agree, stop changing request arguments. The remaining work is in the caller: check whether it calls gzdecode() unconditionally, whether it assumes Content-Encoding implies a compressed body, and whether it treats an empty string from wp_remote_retrieve_body() as a transport failure when it may be a legitimate empty payload.
What not to do first
Do not set decompress => false to “see what happens.” The WP_Http::request() docs note that if you disable decompression and compressed content is returned anyway, it must be separately decompressed — which means you have moved the problem into your code without measuring it. Do not add a Content-Encoding header to the request to force a particular encoding; the request header and the response header are different fields with different semantics. Do not install a plugin to “fix gzip.” The pipeline is four functions deep and observable at each step.
The order that works is: log at http_response, compare against a direct transport fetch, then and only then adjust decompress, stream, or limit_response_size. Each of those arguments changes what the transport does; none of them changes what the remote sent. If the remote sent an empty body with a 200, no argument will conjure content. If the remote sent gzip and the header says gzip, the decompression layer is where the bytes are lost, and Requests::decompress()‘s fail-open behavior is why you did not get an error.
FAQ
Why does wp_remote_retrieve_body() return an empty string instead of an error?
Because it is designed to. Its source returns '' when the response is a WP_Error or when the body key is missing, and the docblock states “Empty string if no body or incorrect parameter given.” It does not distinguish between a missing key, an empty payload, and a failed decompression. Inspect the response array directly before calling it.
Does WordPress decompress gzip responses automatically?
WP_Http::request() defaults decompress to true, and the bundled Requests library implements the decoding in Requests::decompress(). That function returns the input unchanged when the leading bytes do not match a known compression signature, and returns the original data if every decode attempt fails. So “automatic” is accurate only when the payload actually matches the header.
Can a 200 response legitimately have an empty body?
Yes. RFC 9110 defines status codes as describing the response, not guaranteeing a payload. A 200 with zero bytes is a valid response. The question is whether the remote intended to send content and something in the pipeline removed it, which is why the http_response log line and the direct curl comparison are the two measurements that settle it.
Where should I put the diagnostic filter?
In a small must-use plugin, not in the theme. The filter needs to be active for the request that fails, including requests made during cron runs and REST API calls, and a theme’s functions.php is not loaded for every one of those. A mu-plugin also keeps the logger out of the way when you remove it.
What if the body is compressed but decompress is true and the log still shows 1f8b?
Then decompression was skipped or failed silently, and the next step is to read Requests::decompress() against the actual bytes: check the first two bytes, check whether the stream is truncated (compare len against the remote’s Content-Length if present), and check whether a later filter on http_response replaced the body after decompression. The filter runs after the transport, so a filter that rewrites the body is indistinguishable from a transport that never decoded it unless you log both the length and the magic bytes.