{"slug": "freeze-the-error-contract-before-one-except-change", "title": "Freeze the Error Contract Before One except Change", "summary": "A developer argues that mixed None-and-raise APIs are the real product surface and should be pinned with characterization tests before any refactor, since AI coding tools tend to unify error handlers and drop edge cases like a send TypeError. The post presents a worked example of a dispatch_event function with three distinct outcomes — raising ValueError or RuntimeError, returning None, or returning a dict with a status code — and a fixture table specifying escapes, return shape, status, and warning counts for each case.", "body_md": "Mixed None-and-raise APIs fail under model rewrites. The error taxonomy is the real product surface. Pin that taxonomy with characterization tests first, always.\n\nChange one `except` clause only after the pin stays green. Happy-path unit tests will not save this refactor. Callers already branch on `None`, dict keys, and types.\n\nCollapse any one of those shapes and production breaks. The defect is an unrecorded taxonomy, not missing types.\n\nAI coding threads keep offering full-file cleanups. Those cleanups prefer one error type. They also prefer raising over returning `None`.\n\nThat preference is style, not evidence. A messy dispatcher often has three outcomes. Some inputs raise `ValueError` for bad payloads.\n\nSome inputs return `None` after a send failure. Some inputs return `{\"ok\": False, \"status\": 429}`. Downstream code already checks all three shapes.\n\nLoop-style agent edits make the same cut. They unify handlers because duplication looks sloppy. Duplication here is the published contract.\n\nRecord these four fields for every fixture. Skip prose messages on the first pass. Message strings drift across harmless edits.\n\n`None`, mapping keys, or other.\nTypes and keys usually stay stable. Status integers also stay stable. Log counts need a named logger, not the root.\n\nThe table below is the spec. It is a labeled example. It is not a production trace.\n\n| Fixture | Escapes | Return | Status | WARN+ | \n|---|---|---|---|---|\n| empty body | RuntimeError | n/a | n/a | 0 | \n| invalid JSON | ValueError | n/a | n/a | 0 | \n| JSON list | ValueError | n/a | n/a | 0 | \n| missing id | none | None | n/a | 1 | \n| send TypeError | none | None | n/a | 1 | \n| send TimeoutError | none | None | n/a | 1 | \n| downstream 429 | none | dict | 429 | 1 | \n| downstream 200 | none | dict | 200 | 0 | \n\nKeep the `send TypeError` row. That row is the trap. A cleaner `except` often drops it.\n\nThe code is a worked example. Run it locally before any extract. Do not treat it as measured field data.\n\n``` python\n# events.py — labeled example, not a live service\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom typing import Any, Callable\n\nlog = logging.getLogger(\"events\")\n\nSendFn = Callable[[dict], tuple[int, Any]]\n\ndef dispatch_event(raw: str | None, send: SendFn):\n    \"\"\"Messy contract: None, dict, or raise.\"\"\"\n    if raw is None or raw == \"\":\n        raise RuntimeError(\"empty body\")\n    try:\n        payload = json.loads(raw)\n    except Exception:\n        raise ValueError(\"bad json\")\n    if not isinstance(payload, dict):\n        raise ValueError(\"bad json\")\n    if \"id\" not in payload:\n        log.warning(\"missing id\")\n        return None\n    try:\n        status, body = send(payload)\n    except Exception:\n        log.warning(\"send failed\")\n        return None\n    if status >= 400:\n        log.warning(\"downstream %s\", status)\n        return {\"ok\": False, \"status\": status, \"body\": body}\n    return {\"ok\": True, \"status\": status, \"body\": body}\npython\n# test_events_contract.py — worked example\nimport json\nimport logging\n\nimport pytest\n\nfrom events import dispatch_event\n\ndef _records(caplog):\n    return [r for r in caplog.records if r.name == \"events\" and r.levelno >= logging.WARNING]\n\ndef _run(raw, send, caplog):\n    caplog.set_level(logging.WARNING, logger=\"events\")\n    try:\n        value = dispatch_event(raw, send)\n        return None, value, _records(caplog)\n    except Exception as exc:\n        return type(exc), None, _records(caplog)\n\ndef test_empty_body_raises_runtime_error(caplog):\n    exc, value, recs = _run(\"\", lambda p: (200, \"ok\"), caplog)\n    assert exc is RuntimeError\n    assert value is None\n    assert len(recs) == 0\n\n@pytest.mark.parametrize(\"raw\", [\"{\", \"[]\", \"null\"])\ndef test_bad_json_raises_value_error(raw, caplog):\n    exc, value, recs = _run(raw, lambda p: (200, \"ok\"), caplog)\n    assert exc is ValueError\n    assert value is None\n    assert len(recs) == 0\n\ndef test_missing_id_returns_none(caplog):\n    exc, value, recs = _run('{\"name\": \"x\"}', lambda p: (200, \"ok\"), caplog)\n    assert exc is None\n    assert value is None\n    assert len(recs) == 1\n\ndef test_send_type_error_returns_none(caplog):\n    def send(_payload):\n        raise TypeError(\"broken client\")\n\n    exc, value, recs = _run('{\"id\": 1}', send, caplog)\n    assert exc is None\n    assert value is None\n    assert len(recs) == 1\n\ndef test_send_timeout_returns_none(caplog):\n    def send(_payload):\n        raise TimeoutError(\"late\")\n\n    exc, value, recs = _run('{\"id\": 1}', send, caplog)\n    assert exc is None\n    assert value is None\n    assert len(recs) == 1\n\ndef test_downstream_429_is_dict(caplog):\n    exc, value, recs = _run('{\"id\": 1}', lambda p: (429, \"slow\"), caplog)\n    assert exc is None\n    assert value == {\"ok\": False, \"status\": 429, \"body\": \"slow\"}\n    assert len(recs) == 1\n\ndef test_downstream_200_is_dict(caplog):\n    exc, value, recs = _run('{\"id\": 1}', lambda p: (200, {\"n\": 1}), caplog)\n    assert exc is None\n    assert value == {\"ok\": True, \"status\": 200, \"body\": {\"n\": 1}}\n    assert len(recs) == 0\n```\n\nRun the file before editing handlers. Use a short traceback during the first pin.\n\n```\npython -m pytest test_events_contract.py -q --tb=short\n```\n\nA green suite with no mutation check is weak. You must see a failed unified-error fork. Keep that fork out of the merge.\n\n``` python\n# test_events_mutation.py — must fail against a unified raise rewrite\nimport events\n\ndef test_unified_raise_would_break_send_type_error(monkeypatch, caplog):\n    def rewritten(raw, send):\n        payload = __import__(\"json\").loads(raw)\n        status, body = send(payload)  # TypeError now escapes\n        return {\"ok\": status < 400, \"status\": status, \"body\": body}\n\n    monkeypatch.setattr(events, \"dispatch_event\", rewritten)\n    with pytest.raises(TypeError):\n        events.dispatch_event('{\"id\": 1}', lambda p: (_ for _ in ()).throw(TypeError(\"x\")))\n```\n\nExpect this mutation test to fail on the rewrite. Restore the messy handler after that check. The failure is the evidence, not a vibe.\n\n`is None` or catch types.`except` edit.\nStep two is not optional. Tests invented from the callee miss caller branches. Caller branches are the contract.\n\n```\nrg -n \"dispatch_event\\(\" -g \"*.py\"\nrg -n \"is None|except ValueError|except RuntimeError\" -g \"*.py\"\n```\n\nRecord each hit as a fixture name. Missing hits become missing rows. Missing rows make unsafe extracts look safe.\n\nDo not narrow `except Exception` on `send` first. The table says `TypeError` becomes `None`. Narrowing that clause raises `TypeError` instead.\n\nThat raise is a contract break. Extract the block without narrowing. Keep the same log line and `None` return.\n\n``` python\ndef _send_or_none(payload, send):\n    try:\n        return send(payload)\n    except Exception:\n        log.warning(\"send failed\")\n        return None\n\ndef dispatch_event(raw, send):\n    if raw is None or raw == \"\":\n        raise RuntimeError(\"empty body\")\n    payload = _parse_object(raw)\n    if \"id\" not in payload:\n        log.warning(\"missing id\")\n        return None\n    result = _send_or_none(payload, send)\n    if result is None:\n        return None\n    status, body = result\n    if status >= 400:\n        log.warning(\"downstream %s\", status)\n        return {\"ok\": False, \"status\": status, \"body\": body}\n    return {\"ok\": True, \"status\": status, \"body\": body}\n```\n\nThat extract is boring. Boring is the point. The taxonomy stays in the table.\n\nA later change can narrow exceptions. Do that only with a version note. Add a row that expects `TypeError` to escape. Tell callers before you merge.\n\nThe JSON branch is different. `json.loads` should raise `json.JSONDecodeError`. Re-raising `ValueError` is the published type.\n\nNarrowing `except Exception` to `except json.JSONDecodeError` can be safe. Prove it with the invalid JSON row. Prove it with the JSON list row too.\n\n``` python\ndef _parse_object(raw):\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError:\n        raise ValueError(\"bad json\") from None\n    if not isinstance(payload, dict):\n        raise ValueError(\"bad json\")\n    return payload\n```\n\nBoth bad-JSON fixtures must still raise `ValueError`. A leaked `JSONDecodeError` is a taxonomy change. Do not ship that leak without a caller audit.\n\n`from None` also drops `__cause__`. Add a cause fixture if any caller reads it. Skip that fixture when no caller inspects causes.\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach.\n\nA model can draft the extract. It must not invent a new taxonomy. MonkeyCode provides free model access and a free server option.\n\nUse either only after the characterization file is green. Feed the model the table and the test file. Ask for one extract, not a rewrite.\n\nReject a patch that removes a row. Reject a patch that changes `None` into a raise. This is not an agent loop.\n\nOne prompt. One diff. Same tests.\n\nThe harness does not prove semantic equality. It pins types, keys, status, and log counts. It misses timing, retry storms, and byte identity.\n\nLog counts break if a library logs extra warnings. Pin the logger name `events`. Cap propagation on that logger.\n\nDo not pin the root logger. Root pins go red on unrelated imports. That noise hides a real taxonomy drift.\n\nThe table is only as good as the fixtures. One unlisted caller path is an untested shape. Untested shapes are how \"safe\" extracts land in incident channels.\n\nDo not use this flow for greenfield APIs. Design one error shape there. Do not preserve `None` plus raises on purpose.\n\nDo not use this flow for security boundaries. Characterization will pin insecure behavior. Pinning is not hardening.\n\nDo not use this flow without tests you can run offline. A model server cannot replace the table. If pytest cannot collect locally, stop.\n\nTeams with a published OpenAPI error schema may skip dict-key rows. Use the schema as the table instead. Still pin process-local exception escapes.\n\nHTTP schemas often omit those local raises. Omitting them is how `ValueError` turns into a 500. Keep the escape column anyway.\n\nIf a model patch violates any line, drop the patch. Rewrite pressure is not evidence. The table is.\n\nIf the characterization file is already green, one extract prompt on the free server is enough. Skip that prompt when the table still has empty cells.", "url": "https://wpnews.pro/news/freeze-the-error-contract-before-one-except-change", "canonical_source": "https://dev.to/hackrs_6393/freeze-the-error-contract-before-one-except-change-4b8b", "published_at": "2026-09-11 17:33:02+00:00", "updated_at": "2026-09-11 17:43:35.487406+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/freeze-the-error-contract-before-one-except-change", "markdown": "https://wpnews.pro/news/freeze-the-error-contract-before-one-except-change.md", "text": "https://wpnews.pro/news/freeze-the-error-contract-before-one-except-change.txt", "jsonld": "https://wpnews.pro/news/freeze-the-error-contract-before-one-except-change.jsonld"}}