WordPress wp_remote_retrieve_body(): Handle HTTP Responses with Status Codes, JSON, and Rate Limiting

WordPress’s HTTP API (wp_remote_get(), wp_remote_post(), etc.) returns either a WP_Error on connection failure or an associative array containing the response status code, headers, and body. The companion helper functions — wp_remote_retrieve_response_code(), wp_remote_retrieve_headers(), and wp_remote_retrieve_body() — parse this response array safely, returning sensible defaults when the key is absent. Calling these helpers instead of accessing the response array keys directly is the correct pattern: it avoids PHP notices on unexpected response shapes and makes code compatible with any HTTP transport (cURL, fsockopen, or WordPress’s test mock transport). A complete HTTP response handling workflow includes: checking for WP_Error first, validating the status code, checking Content-Type, parsing the body (JSON decode with error checking), and handling rate limiting headers.

Problem: A plugin calls an external JSON API that can return 200 (success), 401 (auth error), 429 (rate limited with a Retry-After header), or 5xx (server error). The plugin needs to handle each case, parse the JSON body safely, and cache successful responses — storing the rate limit reset time in a transient when rate limited.

Solution: Check for WP_Error, retrieve the status code and body via helper functions, switch on the status code, and use wp_remote_retrieve_header() for the Retry-After value.

<?php
function fetch_api_data( string $endpoint ): array|WP_Error {
    $cache_key = 'api_data_' . md5( $endpoint );
    $cached    = get_transient( $cache_key );
    if ( false !== $cached ) {
        return $cached;
    }

    $response = wp_remote_get( $endpoint, [
        'timeout' => 10,
        'headers' => [
            'Authorization' => 'Bearer ' . get_option( 'my_api_key' ),
            'Accept'        => 'application/json',
        ],
    ] );

    // ── Step 1: Check for connection error ────────────────────────────
    if ( is_wp_error( $response ) ) {
        // $response->get_error_message() = 'cURL error 28: Operation timed out'
        return $response;
    }

    // ── Step 2: Read status code ──────────────────────────────────────
    $status = (int) wp_remote_retrieve_response_code( $response );

    // ── Step 3: Handle rate limiting ──────────────────────────────────
    if ( 429 === $status ) {
        $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
        $retry_after = max( 60, $retry_after ); // at least 60 seconds
        set_transient( 'api_rate_limited_until', time() + $retry_after, $retry_after );

        return new WP_Error(
            'api_rate_limited',
            sprintf( __( 'Rate limited. Try again in %d seconds.', 'textdomain' ), $retry_after )
        );
    }

    // ── Step 4: Handle auth error ─────────────────────────────────────
    if ( 401 === $status ) {
        return new WP_Error( 'api_auth', __( 'API authentication failed. Check your API key.', 'textdomain' ) );
    }

    // ── Step 5: Handle server errors ──────────────────────────────────
    if ( $status >= 500 ) {
        return new WP_Error( 'api_server', sprintf( __( 'API server error (HTTP %d).', 'textdomain' ), $status ) );
    }

    // ── Step 6: Read and decode body ──────────────────────────────────
    $body = wp_remote_retrieve_body( $response );
    if ( '' === $body ) {
        return new WP_Error( 'api_empty', __( 'API returned an empty response.', 'textdomain' ) );
    }

    $data = json_decode( $body, true );
    if ( JSON_ERROR_NONE !== json_last_error() ) {
        return new WP_Error(
            'api_json',
            sprintf( __( 'JSON parse error: %s', 'textdomain' ), json_last_error_msg() )
        );
    }

    // ── Step 7: Check Content-Type if needed ──────────────────────────
    $content_type = wp_remote_retrieve_header( $response, 'content-type' );
    // $content_type might be 'application/json; charset=utf-8'

    // ── Step 8: Cache successful response ─────────────────────────────
    if ( 200 === $status ) {
        set_transient( $cache_key, $data, 15 * MINUTE_IN_SECONDS );
    }

    return $data;
}

// ── Usage ──────────────────────────────────────────────────────────────
$result = fetch_api_data( 'https://api.example.com/v1/items' );
if ( is_wp_error( $result ) ) {
    echo esc_html( $result->get_error_message() );
} else {
    foreach ( $result['items'] ?? [] as $item ) {
        echo esc_html( $item['name'] ) . '<br>';
    }
}

NOTE: wp_remote_retrieve_header() (singular) returns a single header value as a string; wp_remote_retrieve_headers() (plural) returns all headers as a Requests_Utility_CaseInsensitiveDictionary object that can be iterated or converted to an array. Header names are case-insensitive in HTTP — the WordPress helper normalises them to lowercase, so always pass lowercase header names: 'content-type', not 'Content-Type'. The wp_remote_get() timeout argument is in seconds and defaults to 5 — increase it for slow external APIs, but keep it reasonable (≤30 s) to avoid holding up PHP worker processes. For non-blocking background HTTP requests, use 'blocking' => false in the args array — the response body will be empty, but WordPress will send the request and immediately return.