HTTP 200 is not proof of JSON: a reproducible Python diagnosis

HTTP 200 is not proof of JSON: a reproducible Python diagnosis

FreeDevelop Studio

A successful HTTP status does not prove the response is JSON. This local, synthetic example separates an HTML response, an HTTP failure, and a malformed JSON body. It is a demonstration, not a customer case.

What the error tells you

A JSONDecodeError by itself does not identify the cause. Check the HTTP status and media type before decoding. An HTML response may be a login page, proxy page, or another endpoint; the response alone does not prove which. The fixture below deliberately returns HTML so the distinction is reproducible.

Run it locally

Save the following as http_json_demo.py and run python3 http_json_demo.py. It uses the Python standard library, starts a temporary server on 127.0.0.1, makes no requests to outside services, and shuts the server down when finished. No API key or account is needed.

"""Synthetic local diagnosis example. Python 3, standard library only."""
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.error import HTTPError
from urllib.request import urlopen


class Fixture(BaseHTTPRequestHandler):
    def do_GET(self):
        cases = {
            "/api": (200, "application/json", b'{"ok": true}'),
            "/login": (200, "text/html", b"<html>Sign in</html>"),
            "/busy": (503, "application/json", b'{"error": "busy"}'),
            "/broken": (200, "application/json", b"{broken"),
        }
        status, media, body = cases.get(
            self.path, (404, "text/plain", b"Not found"))
        self.send_response(status)
        self.send_header("Content-Type", media)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


def checked_json(url):
    try:
        with urlopen(url, timeout=2) as response:
            status = response.status
            media = response.headers.get_content_type().lower()
            if not (media == "application/json" or
                    (media.startswith("application/") and media.endswith("+json"))):
                raise ValueError(f"HTTP {response.status}; expected JSON, got {media}")
            body = response.read(65537)
            if len(body) > 65536:
                raise ValueError("JSON response exceeds this example's 64 KiB limit")
            try:
                return json.loads(body)
            except (json.JSONDecodeError, UnicodeDecodeError) as exc:
                raise ValueError(f"HTTP {status}; JSON content type but invalid JSON body") from exc
    except HTTPError as exc:
        status = exc.code
        exc.close()
        raise ValueError(f"HTTP {status}; inspect the HTTP error before parsing JSON") from exc


if __name__ == "__main__":
    server = HTTPServer(("127.0.0.1", 0), Fixture)
    worker = threading.Thread(target=server.serve_forever, daemon=True)
    worker.start()
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        with urlopen(base + "/login", timeout=2) as response:
            status, body = response.status, response.read()
        try:
            json.loads(body)
            raise AssertionError("The HTML fixture must not parse as JSON")
        except json.JSONDecodeError as exc:
            print(f"Before: HTTP {status} -> {type(exc).__name__}")
        assert checked_json(base + "/api") == {"ok": True}
        print("After /api: JSON object verified")
        expected = {
            "/login": "HTTP 200; expected JSON, got text/html",
            "/busy": "HTTP 503; inspect the HTTP error before parsing JSON",
            "/broken": "HTTP 200; JSON content type but invalid JSON body",
        }
        for path, message in expected.items():
            try:
                checked_json(base + path)
            except ValueError as exc:
                assert str(exc) == message, (path, str(exc))
                print(f"After {path}: {exc}")
            else:
                raise AssertionError(f"Expected a diagnostic for {path}")
        print("PASS: four synthetic cases; localhost only")
    finally:
        server.shutdown()
        server.server_close()
        worker.join(timeout=2)

Observed output

The following output was produced by running this exact script on 29 September 2026. Assertions check all four synthetic responses.

Before: HTTP 200 -> JSONDecodeError
After /api: JSON object verified
After /login: HTTP 200; expected JSON, got text/html
After /busy: HTTP 503; inspect the HTTP error before parsing JSON
After /broken: HTTP 200; JSON content type but invalid JSON body
PASS: four synthetic cases; localhost only

Use the evidence to choose the next step

  • HTML instead of JSON: confirm the endpoint and authentication flow. Do not fix this by swallowing every decoding error.
  • HTTP 503: investigate the service response or an agreed retry policy. Changing the JSON parser does not make an unavailable service healthy.
  • Invalid JSON with a JSON media type: preserve a redacted example and check the response producer.
  • Valid JSON: decoding worked; application-level validation is a separate check.

This small example uses a two-second request timeout and a 64 KiB response limit. It does not implement authentication, retries, or production monitoring. Do not publish real response bodies containing secrets.

Transport failures: before and after HTTP headers

A request can fail before there is any HTTP response. It can also receive a status and headers, then time out while reading the body. This second local fixture shows those two distinct observations; it does not infer a production root cause from an exception name.

Save the code below as http_transport_demo.py and run python3 http_transport_demo.py. It uses only the Python standard library and direct 127.0.0.1 connections, without proxy settings, credentials, or external services. It closes its clients and test server when finished.

"""Two controlled localhost cases; Python 3 standard library only."""
import json
import socket
import threading
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer


def refused_connection():
    # Select and close an ephemeral local port; no outside host is contacted.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reserved:
        reserved.bind(("127.0.0.1", 0))
        port = reserved.getsockname()[1]
    client = HTTPConnection("127.0.0.1", port, timeout=0.2)
    try:
        client.connect()
    except ConnectionRefusedError:
        return {"case": "refused", "phase": "connect", "http_status": None,
                "observation": "ConnectionRefusedError"}
    else:
        raise AssertionError("Port was reused; refusal fixture is inconclusive")
    finally:
        client.close()


def body_read_timeout():
    release_body = threading.Event()

    class Fixture(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", "2")
            self.end_headers()
            self.wfile.flush()
            # Headers arrive, but the body is held until cleanup (3s safety cap).
            release_body.wait(3)
            try:
                self.wfile.write(b"{}")
            except (BrokenPipeError, ConnectionResetError):
                pass  # The timeout client may already have closed its socket.

        def log_message(self, *args):
            pass

    server = HTTPServer(("127.0.0.1", 0), Fixture)
    worker = threading.Thread(target=server.serve_forever,
                              kwargs={"poll_interval": 0.05}, daemon=True)
    worker.start()
    client = HTTPConnection("127.0.0.1", server.server_port, timeout=0.2)
    response = None
    try:
        client.request("GET", "/held-body")
        response = client.getresponse()
        assert response.status == 200
        try:
            response.read()
        except TimeoutError:
            return {"case": "held_body", "phase": "read_body", "http_status": 200,
                    "observation": "TimeoutError"}
        else:
            raise AssertionError("Expected a body read timeout")
    finally:
        if response is not None:
            response.close()
        client.close()
        release_body.set()
        server.shutdown()
        server.server_close()
        worker.join(timeout=2)
        assert not worker.is_alive(), "Fixture worker did not stop"


if __name__ == "__main__":
    results = [refused_connection(), body_read_timeout()]
    assert [r["http_status"] for r in results] == [None, 200]
    for result in results:
        print(json.dumps(result, sort_keys=True))
    print("PASS: two controlled transport cases; localhost only")

Observed on 30 September 2026 with Python 3.12.3 on macOS. Both assertions passed. Native Windows behavior was not tested.

{"case": "refused", "http_status": null, "observation": "ConnectionRefusedError", "phase": "connect"}
{"case": "held_body", "http_status": 200, "observation": "TimeoutError", "phase": "read_body"}
PASS: two controlled transport cases; localhost only

The refused-connection fixture selects and closes an ephemeral local port. If another process reuses it or the OS produces a different result, the fixture fails rather than claiming success. The body fixture deliberately holds two bytes after sending HTTP 200 headers; its 0.2-second timeout is a test setting, not a recommended production timeout.

In a real incident, record the last completed phase and the exception chain. An HTTP status alone does not prove that the response body arrived. A timeout alone does not establish packet loss, a server defect, or an authentication failure. These two cases do not test DNS, TLS, connect timeouts, packet loss, or a remote production environment. Further conclusions require relevant, redacted evidence and agreed client-run checks.

Help with your own Python error

One scoped diagnosis is quoted at $5 after reviewing the issue. The agreed delivery is a Markdown diagnosis, reproducible checks, and a small patch where supported, within 48 hours of confirmed payment, with one correction or clarification. Share a redacted traceback, Python/OS version, expected result, and a minimal snippet of up to 200 lines. Scope is confirmed before payment and custom work.

View the diagnosis service and request a scope review

Questions before ordering? Email a Python diagnosis scope request to freedevelop.studio@proton.me. Include a redacted traceback, Python/OS version, expected result, and a minimal snippet of up to 200 lines. Do not send credentials or customer data.

A scope inquiry does not place an order. Scope and payment terms are confirmed before custom work starts. Existing marketplace orders continue through their original order channel.

Support this free example (optional)

If this reproducible example saved you time, you can support the work with a voluntary contribution. The example remains free to read and run; contributing is optional.

Asset: native USDC. Network: Base. Receiving address: 0x7298f25f55a88047f1dc063179c2be226135aadd

A contribution does not purchase custom work or promise a financial return. For a specific Python problem, use the scoped diagnosis service above.

To identify support for this page, email its URL and the public transaction hash to freedevelop.studio@proton.me with the subject “HTTP JSON tutorial support”.

Report Page