A caller needs more than a three-digit number to decide what happened. Did an HTTP response arrive, did its body arrive completely, and does the application result satisfy the requested operation? These are separate questions. A 200 response can carry a documented application-level failure, or its transfer can break after the headers. A connection failure before any response supplies no HTTP status at all. Preserve those distinctions in diagnostics and metrics.

Classify the evidence you received

An HTTP error response is still a response. It can contain a useful representation, authentication challenge, request identifier, or Retry-After field. Some client libraries raise exceptions for 4xx and 5xx while retaining access to those details. Catch the specific HTTP error before a broader transport exception, and read its relevant fields. Otherwise a meaningful 429 can be flattened into the same generic error as a failed connection.

A successful HTTP exchange can also be an application envelope. For example, a batch or RPC contract may return 200 while individual operations report failure in the body. Interpret the documented schema instead of assuming all such responses mean success or that every such API is necessarily violating HTTP. A monitoring check should validate the outcome it promises to monitor, including the body when that contract requires it.

When it breaks

A status recorded when headers arrive can conceal a later truncated body. Content-Length, stream termination, decompression, and parsing can all affect whether a usable representation was delivered. Report a received status and an incomplete transfer separately. Do not count a partially delivered 200 response as a successful application result simply because the status metric was incremented early.

Retry choices require more context than the first digit. 429 indicates rate limiting and may provide Retry-After. 503 can also suggest a delay. That field can use delay seconds or an HTTP date; neither form promises the server will be healthy at the requested time. Combine server guidance with a bounded policy, backoff, and the operation’s semantics instead of immediately replaying every error.

For a non-idempotent request, a lost response can leave its effect uncertain. The server might have committed before the client lost contact. Repeating without a suitable idempotency contract can duplicate work. Conversely, some 4xx conditions become recoverable after authentication, state, or rate-limit conditions change. Class-wide “always retry” and “never retry” rules erase the information needed to make that decision.

The status also does not identify which component generated it. An origin, gateway, or intermediary may send an error response. Correlate available request identifiers and logs before attributing the failure. A diagnostic value such as curl’s 000 is not a registered HTTP response status; it commonly indicates that no usable HTTP status was obtained.

Independent HTTP status, body completion and application outcomes include 404, 429 and 503; read failures retain received metadata, and one retained Mac trial has a bounded scope.
HTTP: three separate outcomes. The retained Mac trial is scoped below; this diagram and adoption review did not rerun the examples.

Check it quickly

Save this as status-client.py with Python 3.14 assertions enabled. Five loopback responses and a bound-but-not-listening endpoint check classification. The private opener disables proxy discovery only for this fixture, without changing system proxies. The no-response key tests absence of a returned HTTP status, not a particular refusal error; the retained Mac trial observed a timeout on that held endpoint.

from http.client import IncompleteRead
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.error import HTTPError
from urllib.request import ProxyHandler, build_opener
import json
import socket


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/application-error':
            status, data, retry = 200, b'{"ok":false,"error":"demo"}', None
        elif self.path == '/busy':
            status, data, retry = 503, b'{"error":"unavailable"}', '3'
        elif self.path == '/limit':
            status, data, retry = 429, b'{"error":"limited"}', '2'
        else:
            status, data, retry = 404, b'{"error":"missing"}', None
        if self.path == '/truncated':
            self.send_response(200)
            self.send_header('Content-Length', '100')
            self.end_headers()
            self.wfile.write(b'partial')
            return
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(data)))
        if retry:
            self.send_header('Retry-After', retry)
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


opener = build_opener(ProxyHandler({}))


def inspect(url):
    try:
        response = opener.open(url, timeout=3)
    except HTTPError as error:
        response = error
    except OSError:  # Includes URLError and socket timeout/connection errors.
        return {'http_status': None, 'transport_failure': True}
    with response:
        result = {'http_status': response.code, 'retry_after': response.headers.get('Retry-After')}
        try:
            body = response.read()
        except (IncompleteRead, OSError):
            return {**result, 'complete_body': False}
        return {**result, 'complete_body': True, 'payload': json.loads(body)}


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
    origin = f'http://127.0.0.1:{server.server_port}'
    results = {path: inspect(origin + path) for path in
               ('/application-error', '/busy', '/limit', '/missing', '/truncated')}
    with socket.socket() as reserved:
        reserved.bind(('127.0.0.1', 0))  # Still owned, never listening: no HTTP response expected.
        results['no-response'] = inspect(f'http://127.0.0.1:{reserved.getsockname()[1]}/')
    assert results['/application-error']['http_status'] == 200
    assert results['/application-error']['payload']['ok'] is False
    assert results['/busy']['http_status'] == 503 and results['/busy']['retry_after'] == '3'
    assert results['/limit']['http_status'] == 429 and results['/limit']['retry_after'] == '2'
    assert results['/missing']['http_status'] == 404
    assert all(results[path]['complete_body'] is True for path in
               ('/application-error', '/busy', '/limit', '/missing'))
    assert results['/truncated'] == {'http_status': 200, 'retry_after': None, 'complete_body': False}
    assert results['no-response'] == {'http_status': None, 'transport_failure': True}
    print(json.dumps(results))
finally:
    server.shutdown()
    thread.join()
    server.server_close()

The expected application envelope has HTTP 200 and ok: false. The busy and limited endpoints yield 503 and 429, retaining Retry-After values three and two. The missing endpoint remains HTTP 404. A normal return for each of these four cases requires a complete read and JSON parsing; the fixture does not wait or retry.

The truncated endpoint advertises one hundred bytes but sends only partial; IncompleteRead retains status 200 with complete_body: false. Opening catches HTTPError before OSError. During reading, IncompleteRead or OSError, including TimeoutError, retains status and Retry-After while marking an incomplete body. JSON parsing and other protocol failures can still escape; this is not general exception handling.

On 2026-10-18, one supervised macOS 26.5.1 arm64 trial used Python 3.14.4 with -I -B and assertions. A-en and A-zh each ran the complete, identical code body above once and returned all six expected results. Separate B-positive reused inspect, holding a 429 body open until read-phase TimeoutError; it retained 429, Retry-After 2 and complete_body: false. B is not another endpoint here. Four mutants failed their intended assertions: discarding late-body metadata, substituting a received response for no-response, discarding HTTPError metadata, and declaring truncation complete.

The seven cases completed in 19.1647 seconds with 32 observed inspect calls; this is not a benchmark. The inherited environment stayed unchanged, but isolated interpreter flags do not establish default-environment or startup-customization behavior. Cleanup was observed for this run only; no wire capture or production behavior was verified. The displayed socket timeout, shutdown() and unbounded thread.join() do not bound process duration or establish recovery and safe replay. Earlier uncorrected bytes, compile-only records and diagnostic failures remain history; this trial does not retroactively validate them.

Sources