Skip to content
Harness Engineering2026-09-2011 min read

AI Tool Error Handling: Don't Fake an Empty Result

AI tool error handling must distinguish an empty catalog from unavailable data. Use separate result types and a mutation test that catches a swallowed timeout.

Key takeaways

  • An empty successful result and a failed retrieval can both render as an empty Python list unless the code keeps them structurally distinct.
  • A typed result with separate success-with-records and failure-with-code branches stops a timeout from silently becoming a zero-stock reading.
  • Bounding retries by attempts and elapsed time, as AWS Well-Architected recommends, is a separate policy from deciding what a failure means downstream.
  • A mutation test should turn a timeout into fake success and confirm the regression test detects the incorrectly running shortage calculation.
  • Preserving an operation identifier and adapter version on every error lets a reviewer trace exactly where a failure was erased.

Rod Rivera

Author

AI Tool Error Handling: Don't Fake an Empty Result

Rod's note — read with a pencil; the margins are for you.

When a list of zero items lies

Suppose a warehouse system asks an inventory service for current stock on a flavor of ice cream and gets back []. Does the API contract mean no matching product records, zero physical units, or did the request never actually reach the inventory service? An empty list only has the meaning assigned by the API contract. A swallowed exception can produce that same Python value without establishing any inventory fact. If your code treats "I asked and got nothing" the same as "I couldn't ask," you have built a system that cannot tell a legitimate zero from a missing answer. That gap is where a shortage calculation quietly runs on data that was never obtained, and where a downstream decision (reorder nothing, cancel an order, tell a customer the item is gone) gets made on a foundation that was never checked.

This is not a rare edge case. It is the default failure mode of code that wraps a network call in a try/except and returns an empty container when the call fails, because an empty container is a convenient, type-compatible stand-in for "nothing to report." The convenience is exactly the problem: the type checker is satisfied, the function signature looks clean, and the bug hides behind a return value that looks completely ordinary.

Three outcomes, not two

Before writing any code, it helps to name the outcomes precisely, because "success or failure" is too coarse. A request for inventory data can land in at least three distinct places:

OutcomeWhat actually happenedCorrect downstream action
Success, emptyThe service returned a complete catalog with zero product recordsZero shortage rows; this does not establish that a known product has zero units
UnavailableThe service timed out, refused the connection, or never respondedDo not compute a shortage; retry within a bounded policy or stop and flag
MalformedThe service responded, but the payload does not match the expected shapeReject the response; do not attempt to extract partial data silently

Only the first row is evidence about the state of the catalog. The second and third rows establish an unavailable or unusable response, not a valid inventory snapshot. Collapsing all three into [] throws away exactly the information a reviewer would need to tell them apart later.

A typed result instead of a bare list

The fix is to stop returning a bare list from the function that talks to the inventory service, and return a small tagged structure instead. Python's standard library gives you everything needed for this without a new dependency:

python
from dataclasses import dataclass
from uuid import uuid4

@dataclass
class InventoryOK:
    records: list
    operation_id: str
    source_adapter_version: str

@dataclass
class InventoryError:
    code: str
    retryable: bool
    operation_id: str
    source_adapter_version: str


def fetch_inventory(shop_id, adapter) -> InventoryOK | InventoryError:
    operation_id = str(uuid4())
    try:
        response = adapter.get(shop_id, timeout=2.0, operation_id=operation_id)
    except (TimeoutError, ConnectionError):
        return InventoryError("inventory_unavailable", True, operation_id, adapter.version)
    if not adapter.is_well_formed(response):
        return InventoryError("malformed_response", False, operation_id, adapter.version)
    return InventoryOK(response["records"], operation_id, adapter.version)


def calculate_shortages(result):
    if isinstance(result, InventoryError):
        return {"status": "blocked", "code": result.code}
    if not isinstance(result, InventoryOK):
        raise TypeError("Unknown result type")
    rows = [{"sku": r["sku"], "quantity": r["target"] - r["on_hand"]}
            for r in result.records if r["target"] > r["on_hand"]]
    return {"status": "calculated", "rows": rows}

The error type has no records field. The consumer branches on the actual result type before iterating records. Dataclass annotations alone do not validate wire data: the adapter must verify the response shape before constructing InventoryOK. A tagged JSON representation needs the same checks after deserialization.

The operation_id and source_adapter_version fields matter for a different reason. They are not for the immediate decision; they are for the person who has to reconstruct, later, exactly which call failed, against which version of the adapter, so the failure can be traced rather than merely logged as a vague "error occurred." This mirrors the point made in Anthropic's guidance on writing tools for agents (https://www.anthropic.com/engineering/writing-tools-for-agents, 2025-09-11): how a tool reports its outcome and what context it carries directly shapes whether the calling agent, or the human reading its trail, can use that outcome correctly.

The retry question is separate from the meaning question

It is tempting to fold "how many times do we retry" into the same code path as "what does this result mean," but they answer different questions and should be decided separately. AWS Well-Architected's guidance on limiting retries (https://docs.aws.amazon.com/wellarchitected/2023-04-10/framework/rel_mitigate_interaction_failure_limit_retries.html, 2023-04-10) recommends bounding attempts, applying backoff and jitter, and being careful that retries do not multiply silently across layers of a system. That is a policy about how many times and how soon to try again. It says nothing about what a failed attempt should be interpreted as once the retry budget is exhausted. Those are two separate design decisions, and conflating them is how a system ends up retrying three times, still failing, and then defaulting to "must be empty" simply because that was the easiest value to return.

A bounded policy for the inventory example might allow two attempts within a three-second window. An outer retry controller would return a terminal error after exhaustion; the one-attempt function shown above does not implement that controller, and the caller is responsible for deciding what "blocked" means for its job: pause the workflow, alert a human, or fall back to a cached snapshot with its own timestamp clearly attached. None of those fallback choices should ever be "treat it as zero units."

Idempotency is a separate problem from timeouts

A timeout on a read is usually safe to retry. A timeout on an action with side effects, like placing an order, is not automatically safe to retry, because the original request may have already succeeded on the far side even though your client never saw the response. Retrying blindly can create a duplicate. Check an operation identifier or receipt before retrying any call that changes state.

Walking the mechanism end to end

Here is the full path a request takes, from the caller's first attempt to the decision a downstream shortage calculator is allowed to make:

Where a failure must stay a failure

Read the two paths separately: an inventory request either produces a well-formed reply that becomes an "ok" result feeding the shortage calculation, or it produces one of two distinct error states that route to a bounded retry or a blocked state, never to the shortage calculation directly.

Separate success and error types

Define the result type with separate branches for success-with-records and failure-with-code before writing the network call itself; retrofitting the type after the call exists tends to leave old call sites returning bare lists.

Preserve adapter failures

Write the adapter so every failure path returns the error branch with a specific code, an operation identifier, and the adapter version, never a bare empty container.

Branch before reading records

Write the downstream consumer so it can only read records from the "ok" branch, ideally by having the type system or a runtime assertion refuse to compile or run otherwise.

Testing that the distinction actually holds

A single passing test that checks "empty catalog returns empty records" does not prove the system distinguishes failure from emptiness. You need a small table of scenarios and, critically, a test that tries to break the distinction on purpose:

ScenarioExpected branchShould shortage calculation run?
empty_successok, records=[]Yes, compute zero required rows
timeout_errorerror, code=inventory_unavailableNo
malformed_error_envelopeerror, code=malformed_responseNo

The useful test is not the one confirming each row independently. It is a mutation test: take the adapter, force timeout_error to return {"status": "ok", "records": []} instead of the proper error branch, and confirm that the test suite now fails, specifically the test asserting that shortage computation does not run on a timeout. If that test still passes after the mutation, your regression suite was not actually checking the distinction, it was checking that some value came back, which any bug would still satisfy.

Keep the positive control too: a test confirming that a genuinely empty catalog still triggers zero-shortage computation correctly. Without that control, an overzealous fix ("never trust an empty list, always block") could pass the failure tests while breaking the perfectly legitimate empty-catalog case, which is its own kind of wrong answer.

Quick check — An inventory read times out and the function returns []. Does this establish that the flavor is out of stock?

Check the decision

Here is a local adapter fixture and a deliberate mutation. The adapter contract normalizes network exceptions to the standard Python types used above; a real SDK may raise different exception classes.

python
class FixtureAdapter:
    version = "fixture-v1"
    def __init__(self, mode):
        self.mode = mode
    def get(self, shop_id, timeout, operation_id):
        if self.mode == "timeout":
            raise TimeoutError("injected")
        if self.mode == "malformed":
            return {"unexpected": []}
        return {"records": []}
    def is_well_formed(self, value):
        return (isinstance(value, dict) and set(value) == {"records"}
                and isinstance(value["records"], list)
                and all(isinstance(row, dict)
                        and set(row) == {"sku", "on_hand", "target"}
                        and isinstance(row["sku"], str)
                        and type(row["on_hand"]) is int and row["on_hand"] >= 0
                        and type(row["target"]) is int and row["target"] >= 0
                        for row in value["records"]))

empty = fetch_inventory("shop-1", FixtureAdapter("empty"))
assert calculate_shortages(empty) == {"status": "calculated", "rows": []}
failed = fetch_inventory("shop-1", FixtureAdapter("timeout"))
assert calculate_shortages(failed)["status"] == "blocked"
malformed = fetch_inventory("shop-1", FixtureAdapter("malformed"))
assert malformed.code == "malformed_response"
# Deliberately reproduce the broken adapter behavior after a timeout.
mutated = InventoryOK([], failed.operation_id, failed.source_adapter_version)
try:
    assert calculate_shortages(mutated)["status"] == "blocked"
except AssertionError:
    print("Mutation detected: fake success incorrectly reaches calculation")
else:
    raise AssertionError("Regression assertion did not catch the mutation")

A timeout mutation incorrectly reaches shortage calculation and fails the expected blocking assertion

A teaching precedent for the same mistake

This distinction between a failure and a legitimate empty result is not a hypothetical concern invented for this article. A course lesson on failure handling in an inventory and purchasing context describes a case where a class demonstration model responded and then stopped responding mid-session, with the underlying cause left unknown (see the Class 7 lesson on failures, work sheets, and review at /courses/zeo-itam-autumn-2026). That lesson makes a related point using a work-sheet record rather than a typed result: a durable agent records failed attempts alongside successful ones, and a log containing only accepted results conceals the cost, and the risk, of the ones that failed silently. The mechanism differs from the typed-result approach shown here, but the underlying discipline is the same: never let a missing answer masquerade as a complete one.

What would prove this wrong

If a system's downstream consumer genuinely cannot distinguish an ok empty list from an error result at runtime, despite the type separation, the fix has failed. That can happen if a serialization layer flattens the dataclass to JSON and back without preserving the status field, or if an older code path bypasses the typed result and calls the raw adapter directly. The mutation test above is the concrete check: if forcing a timeout into an ok, records=[] shape does not break a test, the separation exists in your source code but not in your enforced behavior, and that gap is exactly where a real timeout will eventually get read as real zero stock.

See failure recovery and work-sheet records in the full course

For the fuller treatment of bounding retries, writing work sheets, and independent review of agent failures, continue with the Class 7 lesson in the ZEO ITAM course.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.