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:
| Outcome | What actually happened | Correct downstream action |
|---|---|---|
| Success, empty | The service returned a complete catalog with zero product records | Zero shortage rows; this does not establish that a known product has zero units |
| Unavailable | The service timed out, refused the connection, or never responded | Do not compute a shortage; retry within a bounded policy or stop and flag |
| Malformed | The service responded, but the payload does not match the expected shape | Reject 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:
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."
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:
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.
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.
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.
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:
| Scenario | Expected branch | Should shortage calculation run? |
|---|---|---|
empty_success | ok, records=[] | Yes, compute zero required rows |
timeout_error | error, code=inventory_unavailable | No |
malformed_error_envelope | error, code=malformed_response | No |
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.
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.
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 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.
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.

