A TypeError stopped a small Python tagging helper on an unpaid invoice path. The helper expected a list of tags, yet one caller invoked it with no second argument. I had forty-eight hours of field notes, a clean remote shell, and a model eager to patch the crash. Would you have shipped that default argument after one isolated unit test passed locally?
This write-up is a field notebook, not a victory lap. I record what I tried, what broke under a second call, and what I would repeat on the next TypeError. The reusable artifact is a two-call test plus a short review table for default-argument patches.
Hour 0–8: the crash looked like a missing list
The helper lived in a billing script that attached tags to an invoice record. Production called it twice for the same customer when a retry queued behind a timeout. My first local reproduction used a single call, because that was the line in the traceback.
# invoice_tags.py — broken production shape, simplified
def attach_tags(invoice_id: str, tags: list[str]) -> list[str]:
tags.append(f"invoice:{invoice_id}")
tags.append("source:billing")
return tags
def tag_unpaid_invoice(invoice_id: str) -> list[str]:
return attach_tags(invoice_id)
TypeError: attach_tags() missing 1 required positional argument: 'tags'
Does that traceback invite a default argument? It invited mine. I reproduced the crash with python -c, then with a pytest file that called tag_unpaid_invoice once. Both failed the same way, so I treated the missing list as the whole story.
Hour 8–16: I asked a free model for a patch
I wanted a second pair of eyes that was not already staring at my traceback. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to propose a patch, then ran the same files on MonkeyCode's free server so a clean interpreter could contradict my laptop.
The prompt was boring on purpose. I pasted the function, the TypeError, and the single-call test. I did not mention retries, shared lists, or a second request. The model did what most helpers do with a missing argument: it added a default.
# invoice_tags.py — the first patch I almost shipped
def attach_tags(invoice_id: str, tags: list[str] = []) -> list[str]:
tags.append(f"invoice:{invoice_id}")
tags.append("source:billing")
return tags
def tag_unpaid_invoice(invoice_id: str, extra: list[str] | None = None) -> list[str]:
if extra is None:
return attach_tags(invoice_id)
return attach_tags(invoice_id, extra)
# test_invoice_tags.py — the test that lied to me
from invoice_tags import tag_unpaid_invoice
def test_unpaid_invoice_gets_source_tag():
tags = tag_unpaid_invoice("inv_100")
assert "invoice:inv_100" in tags
assert "source:billing" in tags
That test passed on the laptop. It also passed on the free server, which mattered, because I had already burned hours blaming local sitecustomize in other weeks. Why would I distrust a green test that reproduced on a clean machine?
Hour 16–24: the second call was the real test
Billing retries are not academic. The queue called tag_unpaid_invoice("inv_100") and later tag_unpaid_invoice("inv_200") inside one process. I finally wrote the second call after a log line showed invoice 200 wearing invoice 100's tag.
# test_invoice_tags.py — the test I should have written first
from invoice_tags import tag_unpaid_invoice
def test_two_invoices_do_not_share_tags():
first = tag_unpaid_invoice("inv_100")
second = tag_unpaid_invoice("inv_200")
assert first is not second
assert "invoice:inv_100" not in second
assert "invoice:inv_200" not in first
AssertionError: assert 'invoice:inv_100' not in [
'invoice:inv_100', 'source:billing',
'invoice:inv_200', 'source:billing',
]
The default list was created once, when the function object was defined. Every later call appended onto the same object. The free server made that painfully clear, because there was no local cache and no leftover pytest worker to blame.
Have you ever watched a model apologize and then offer tags = tags or [] as if that were different? I did. or [] still returns the same default object when the caller omits the argument. The honest fix allocates a new list on every call.
# invoice_tags.py — the patch I kept
def attach_tags(invoice_id: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags = list(tags)
tags.append(f"invoice:{invoice_id}")
tags.append("source:billing")
return tags
def tag_unpaid_invoice(invoice_id: str, extra: list[str] | None = None) -> list[str]:
return attach_tags(invoice_id, extra)
The list(tags) copy is deliberate. Callers that pass their own list should not see billing suffixes appear in a cache they still hold. Would you skip that copy because the type hint already says list[str]?
The artifact: a two-call plan you can run today
Label this as a reproduction recipe, not a benchmark. I ran it with Python 3.12 and pytest 8 on a local venv, then again on the free server so the process started clean.
Commands
python -m venv .venv
. .venv/bin/activate
python -m pip install pytest
python -m pytest -q test_invoice_tags.py
Numbered checks I now run after any default-argument patch
- Call the function twice in one test process, with different inputs, and compare identities.
- Pass an explicit list from the caller, then assert the caller's list was copied or documented as mutated.
- Restart the interpreter and run the same two calls; do not trust a warm pytest worker alone.
- Search the patch for
= [],= {}, and= set()on function signatures before you merge. - If a model wrote the default, ask it to add the two-call test before you accept the production change.
Decision table for reviewing the patch
| Symptom after the first fix | Likely cause | Keep the patch? | Next probe |
|---|---|---|---|
| Single-call test green, second call mixed IDs | Mutable default list or dict | No | Two-call identity test |
| Second call clean, caller list gained extra tags | In-place append on caller object |
No | Assert caller copy unchanged |
| Fails only in one long-lived process | Module-level cache, not the default | Maybe | Restart interpreter, then retry |
| Fails only when tests run together | Shared fixture or imported singleton | Maybe |
pytest --count=2 on one file |
| Clean server and laptop disagree | Local sitecustomize or extra path |
Investigate | Compare python -c "import sys; print(sys.path)"
|
What broke besides the list
The model was not wrong about the TypeError. It was wrong about the lifetime of the object it created to silence that error. I also broke my own review by running pytest test_invoice_tags.py::test_unpaid_invoice_gets_source_tag, which never constructed a second invoice.
A second failure hid behind the first. tag_unpaid_invoice returned the same list object the helper mutated, and a caching layer stored that object under the customer id. Even after I fixed the default, I had to copy before return, or the cache and the next invoice still aliased each other. Do you test return identity, or only membership of strings?
def test_return_value_is_not_reused():
a = tag_unpaid_invoice("inv_100")
b = tag_unpaid_invoice("inv_100")
assert a == b
assert a is not b
That test is cheap. It would have caught both the mutable default and the missing copy. I did not write it until hour thirty-six, after the free server showed the mixed tags without any of my local pytest plugins loaded.
What I would repeat
I would still paste a crashing function into a free model, because the first TypeError is often exactly what it looks like. I would still run the candidate patch on a free server, because my laptop has history that a fresh process does not. I would not accept a default value until a two-call test exists in the same file as the helper.
- Repeat: one clean interpreter, two calls, different ids, identity assertions.
- Repeat: search diffs for
= []the same way I search forexcept: pass. - Repeat: ask the model for the test before I ask it for another production patch.
- Skip: any explanation that starts with "this should be fine because tests passed."
The useful question for the model is not "how do I stop the TypeError?" The useful question is "what shared object survives between the first call and the second?" That prompt change did more than a longer stack trace.
Limitations, and who should not use this
This notebook is about function defaults and in-process retries. It does not prove anything about model quality, server speed, or how often a patch is right. I did not collect timings, quotas, or hardware details, and you should not treat this as a product comparison.
Do not use a two-call pytest file as a stand-in for load testing. A mutable default is a process-local aliasing bug; it will not explain a race across workers, a Redis cache, or a database unique constraint. If your tags live in SQL, write a transaction test, not another list identity assertion.
Skip this workflow if you cannot run the helper twice in one process. Skip it if the function must mutate a caller-owned list as part of a documented API. Skip it if you need guaranteed model availability, because free access and a free server are convenience, not a contract I can measure here.
I still keep the review table above the prompt history. The TypeError was honest. The first patch was helpful, and it was also wrong in a way a single-call test cannot see. If you want a second process that never inherited your laptop's defaults, MonkeyCode's free server is one place I reran the two-call file before I trusted the helper again.
Top comments (0)