I fixed a small timezone bug, ran the suite, and watched all 236 tests go green. Out of habit more than doubt, I pushed to CI before tagging the release. CI went red — and then it went redder. That one small fix had two more bugs stacked underneath it, and every one of them was invisible on the machine where I’d just watched the tests pass.
The setting is a tool I maintain called claude-code-notify: it pings me when Claude Code hits a usage limit, and again when the limit resets. You don’t need to know the tool. The bug is an ordinary timezone bug, and what it cost me is a lesson about tests — about how a completely green local suite can be lying to your face.
The original bug lived in the part that computes when the limit resets.
Right only by coincidence
When Claude Code tells you a limit will reset, it writes the time into the message as plain text, with the zone in parentheses: resets 5:20am (Asia/Hong_Kong). The function that read that time, parse_reset(), pulled out the hour and minute and did all the arithmetic in the host machine’s local time. It never looked at the zone in the parentheses.
The host machine, in my case, is a remote server pinned to Hong Kong time. So the zone Claude Code embedded and the zone my code silently assumed were the same zone — Asia/Hong_Kong on both sides — and the answer came out right every single time. Not because the code was correct. Because two timezones I’d never thought to compare happened to be identical. Run the same tool on a laptop in London, or catch a reset that Claude Code reports in another zone, and the “your limit resets at…” ping lands hours off, with no exception and no log line to explain it — just a confident, wrong time.
The fix was small: capture the zone name out of the text and resolve it through Python’s stdlib zoneinfo, falling back to host-local time only when the zone is missing or unresolvable — which is exactly what the code used to do always, now demoted to a deliberate last resort. The tests passed. This is the point where I should have been suspicious and wasn’t, because locally there was nothing to be suspicious of.
The test that couldn’t fail
ubuntu-latest runs in UTC. The first CI run came back 4 failed, 232 passed, and two of the failures were in tests I hadn’t touched at all:
tests/test_usagelimit.py::test_parse_reset_returns_next_local_occurrence FAILED
tests/test_usagelimit.py::test_parse_reset_rolls_to_tomorrow_when_past FAILED
E assert (13, 0) == (21, 0)
Enter fullscreen mode Exit fullscreen mode
Both checked parse_reset() against resets 9pm (Asia/Hong_Kong). Both built the “current time” they fed in from a plain datetime — read as host-local — and then verified the parser’s answer by converting it back, also as host-local. The same ambient timezone sat on both sides of the assertion.
Under the old, buggy parse_reset(), which also worked in host-local time, that symmetry was fatal in a quiet way: feed in host-local, compute in host-local, read back in host-local, and the timezone cancels straight out of the equation. The two sides matched on every machine on earth. The test read like it was verifying timezone handling. It was verifying that a number equals itself. It could not fail — not on my box, not on anyone’s.
Bug 1’s fix broke the symmetry, which is the only reason any of this surfaced. parse_reset() now resolves 9pm Hong Kong to a real instant, the same instant no matter what the host clock says. On my Hong Kong machine, reading that instant back as host-local still shows 21:00 — still green. On the UTC runner, the identical instant reads back as 13:00, because 9pm in Hong Kong is 1pm UTC. (13, 0) == (21, 0): an eight-hour gap that appears the moment the code runs somewhere my clock doesn’t.
This is the one to keep. A test that derives its expected value from the same environment the code reads is self-consistent, and on a green dashboard self-consistent is indistinguishable from correct. It passes, so you trust it, and it is checking nothing. The repair is to anchor the expectation to something external and explicit — both the input time and the assertion pinned to zoneinfo.ZoneInfo("Asia/Hong_Kong") instead of “whatever this machine calls local.” I re-ran it under TZ=UTC, TZ=America/Los_Angeles, and TZ=Pacific/Kiritimati before pushing again, so the test now asserts a fact about the world instead of a fact about my machine.
The tests didn’t fail the way the code would
Second push. Red again, and differently:
tests/test_usagelimit.py::test_parse_reset_uses_reported_timezone_not_host FAILED
E ModuleNotFoundError: No module named 'zoneinfo'
Enter fullscreen mode Exit fullscreen mode
zoneinfo only entered the standard library in Python 3.9. The tool supports 3.8 — pyproject.toml declares requires-python = ">=3.8" — and the CI matrix runs 3.8 on purpose. The production code already accounted for that: the import of ZoneInfo sits behind a try/except ImportError, and when it fails the code falls back to host-local time, the same fallback Bug 1’s fix leans on. It bends.
The tests didn’t bend. The four that exercise timezone resolution — the two I’d just anchored, plus two I’d added alongside the fix — each did a bare from zoneinfo import ZoneInfo. On 3.8 that isn’t a graceful fallback; it’s a hard ModuleNotFoundError before the test body even runs. The code under test degraded, and the tests meant to cover it snapped instead.
The fix is one line apiece — pytest.importorskip("zoneinfo") — which skips the test cleanly when the module isn’t there. The principle behind it is duller than either earlier bug and matters more than both: a test has to degrade the same way the code it covers degrades. If production tolerates a missing optional dependency, a test that hard-requires that dependency isn’t testing production. It’s testing a stricter promise the code never made.
Red that meant something, and red that didn’t
One more job in that run was red, and it was bait. macos-latest on Python 3.8 showed as failed, sitting right next to the genuine 3.8 failure on Ubuntu — close enough to look like the same bug. It wasn’t. Its log said The operation was canceled, at the setup-python step, before a single test ran. When one leg of a matrix fails, GitHub Actions cancels the rest by default, and my workflow never turns that off — so the real Ubuntu failure dragged the entire macOS column down with it, 3.8, 3.11 and 3.12 all cancelled, not one of them an actual bug. Chase that red as a fourth problem and you lose an afternoon debugging a cancellation. Telling a real failure apart from fail-fast noise is its own step; the color alone won’t do it for you.
Line the three real bugs up and they rhyme. Bug 1 needs a host whose timezone differs from the reported one. So does Bug 2. Bug 3 needs a Python without zoneinfo. My dev machine is in Hong Kong, on Python 3.12 with zoneinfo built in — the one configuration on which not a single one of the three can happen. I didn’t get unlucky and overlook them. The machine I built on was, structurally, the exact machine where all three were invisible. Every green run I’d trusted was true and worthless in the same breath: the code agreed with the computer that ran it. That is the whole of what a green suite proves. Whether the code is correct is a separate claim, and the space between those two claims is precisely where a machine’s timezone, its Python version, and its installed modules go to hide.
The fix that actually mattered wasn’t any of the three patches. It was running the matrix before tagging, instead of reading “236 passed” on one box as if it meant “shippable.” Any of the three patches was ten minutes’ work; the step I’d been skipping was the cheap, redundant-feeling one — letting a machine that isn’t yours try the code before you call it done.
The tool is claude-code-notify — MIT-licensed, at github.com/Jeromefromcn/claude-code-notify, correct reset timezones now included. If Claude Code’s usage limits have ever cost you an afternoon, it might be worth a look. And if you’d rather read the bugs than the writeup, they’re three commits in the history — one per bug.
답글 남기기