fix: the defects the agents page turned up - #22
Conversation
…inator is found The decorator only ever looked in **kwargs for the key, so a key passed positionally was invisible and the operation ran unprotected. It now resolves the key from the positional arguments too, when the parameter can be passed that way. Failing to find a coordinator still degrades to running the function, but it is now logged as a warning instead of happening in silence.
…er decode encode() returned null for a falsy value and decode() rejected a falsy payload, so an action returning None stored a record that failed to decode for the life of that record: every replay re-ran the action, collided on save, and failed to decode the winner. A model that dumps to an empty mapping or list hit the same decode guard. encode() now refuses None outright -- reported as record_validation_error and swallowed by the coordinator, so the operation goes uncached rather than poison-cached -- and decode() only rejects a null payload.
The ImportError told the reader to install redis-client-kit and offered a [redis-aio] extra; the declared extra is [redis] and the requirement is redis plus orjson. The architecture page named the same missing extra, and the API reference described the repository's redis argument as a redis-client-kit client when it is any redis.asyncio.Redis.
The repository and the coordinator both recorded hit, miss, collision and latency for the same call, and the shipped providers give them one APP-scoped collector, so every coordinator-driven get produced two misses and two method="get" observations. The coordinator now owns those four -- its hit and miss are the truthful ones, since a record that fails to decode is a miss for the caller. The repository keeps what the coordinator cannot produce: its error counters, the bulk hit and miss counts of get_many, and the latency of delete and get_many.
IdempotencyDomainService.validate_record and IdempotencyRecordExpiredError were public with no caller in the library, and the coordinator trusted the repository to never hand back an expired record. The shipped Redis repository does check, but the rule belongs to the domain and a backend without native expiry cannot enforce it, so a third-party repository replayed stale results forever. The coordinator now validates every record it reads and counts an expired one as a miss.
BaseIdempotencySettings.enabled was read by nothing: setting it to False changed no behaviour anywhere in the library. AsyncIdempotencyCoordinator now takes enabled (default True), and the shipped coordinator provider passes the settings flag through, so False makes every call a pass-through to the action. The provider reads the flag with getattr so a settings object written against the protocol before enabled was part of it keeps working.
IdempotencyDomainService defaulted to 30 minutes, a floor of 60 seconds and a ceiling of 24 hours, while BaseIdempotencySettings shipped 60 minutes, a floor of 1 second and a ceiling of 30 days -- so the effective bounds depended on whether the service was built by hand or from the settings object. core/constants.py is now the single source and the settings model takes its field defaults from it. The wider pair won on both bounds, because narrowing them would have started rejecting TTLs that work today, and the coordinator swallows an out-of-range TTL: the operation would have gone quietly uncached. BREAKING CHANGE: DEFAULT_TTL_MINUTES is 60 (was 30) and MAX_TTL_SECONDS is 2592000 (was 86400), so IdempotencyDomainService() built without arguments now keeps records for an hour and accepts a TTL of up to 30 days. BaseIdempotencySettings.min_ttl_seconds defaults to 60 (was 1), which no shipped path can observe: the coordinator already floors every TTL at one minute. Pass the arguments explicitly to keep the old values.
agents.md stated four of the defects as rules -- the silent coordinator lookup, the positional key the decorator could not see, the poison null from PydanticResultAdapter, and both layers counting the same get -- so those rules had to go with the code. The TTL defaults, the coordinator signature and the settings field list follow the new canonical numbers, and the reference, guide, quickstart and architecture pages carry the same changes.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The decode guard for a null payload, the infra_param-on-self lookup and the fallback for a callable inspect cannot describe.
|
One thing I ran into and did not touch, since it is CI configuration rather than one of the findings.
Locally the full suite is 120 passed at 97% total, and |
Writing
docs/agents.mdagainst the source turned up eight things. All eight reproduced;all eight are fixed here. One of them changes public defaults and is marked breaking.
Every code fix has a test that fails on
origin/masterand passes here — I checked each oneby restoring the old file under the new test.
1. The decorator's key and coordinator lookup
kwargs.get(key_param)was the only place the key was read, so a key passed positionally wasinvisible: the function ran unprotected and nothing said so. Failing to find a coordinator did
the same — no exception, no log.
Reproduction: a decorated function called as
act("key-1", coordinator)never reachedcoordinate(); a decorated function with a key and no coordinator emitted zero log records.The decorator now resolves the key from the positional arguments too, when the parameter can be
passed that way (the index is computed once, at decoration time, from the signature), and logs a
WARNINGfromidempotency_kit.core.decorators.aio.idempotentbefore running unprotected.The lookup itself is unchanged, including the
vars()scan of every positional argument. It isloose, and I left it alone rather than tightening it in the same pass: narrowing it would turn a
working
infra_param-less wiring into a silent miss for someone.infra_param=still takes thevalue at its word, so a duck-typed coordinator keeps working.
Tests:
test__decorator__positional_key__calls_coordinator,test__decorator__keyword_only_key__is_not_read_from_positional_arguments,test__decorator__no_coordinator__warns.2.
PydanticResultAdapterstored a record it could never decodeencodereturnedNonefor a falsy value anddecoderaised on a falsy payload.Reproduction: an action returning
NoneunderPydanticResultAdapter, called three timesthrough a real coordinator and repository, ran three times and left
{"result": null, ...}in Redis — every replay was a decode failure, a re-execution, acollision on save and a second failed decode. Separately, a model that dumps to
{}or[](an acknowledgement with no fields, a
RootModel[list[int]]that is empty) encoded fine andthen failed to decode.
encodenow refusesNonewithIdempotencyValidationError, which the coordinator alreadyhandles:
record_validation_error, one log line, result returned, operation uncached.decodenow rejects only a null payload, so an empty model round-trips.
Tests: the
pydantic-empty-modelcase in the round-trip parametrize, andtest__coordinator__pydantic_adapter_on_a_none_result__stores_nothing_and_reports_it.3. The Redis
ImportErrornamed a package and an extra that do not existIt asked for
redis-client-kitand offeredidempotency-kit[redis-aio]; the requirement isredisplusorjsonand the declared extra isredis.docs/architecture.mdrepeated the[redis-aio]spelling.The test is mechanical rather than a string match: it pulls the extra out of the message and
asserts the distribution declares it (
Provides-Extrafrom the installed metadata), so themessage cannot drift from
pyproject.tomlagain.Test:
test_repository_import_error_names_the_declared_extra.4. The API reference described the wrong client
docs/api_reference.mdcalled the repository'sredisargument an instance of aredis-client-kitclient. It is anyredis.asyncio.Redis, subclasses included — which is whyfakeredisworks in the tests and an instrumented client works in production. Documentationfix only; the code was right.
5. Double-counted metrics
The repository and the coordinator each recorded hit, miss, collision and latency for the same
call, and
IdempotencyProviderhands both the same APP-scoped collector.Reproduction: one coordinator-driven miss produced two
record_misscalls and twomethod="get"latency observations; one hit produced tworecord_hitcalls.Each metric now has one owner. The coordinator keeps hit, miss, collision and the latency of
getandsave— its hit and miss are the truthful ones, since a record that fails to decodeis a miss for the caller and a hit for storage. The repository keeps what the coordinator cannot
produce: its error counters, the bulk hit and miss counts of
get_many, and the latency ofdeleteandget_many.This changes what a directly-driven repository emits:
repo.get()andrepo.save()on theirown no longer count a hit, a miss, a collision or their latency. Dashboards built on the shipped
wiring get correct numbers instead of doubled ones; a dashboard built on a bare repository loses
those series. I could not find a fix that keeps both without one of them being wrong.
Tests:
test_metrics_comprehensive(rewritten) andtest_metrics_shared_with_coordinator_count_each_operation_once.6.
BaseIdempotencySettings.enabledwas read by nothinggrepfor readers of the field in the package returns nothing: setting it toFalsechangedno behaviour anywhere.
Rather than drop a field people already have in their config, I gave it the meaning it claims:
AsyncIdempotencyCoordinatortakesenabled=Trueandcoordinate()becomes a pass-throughwhen it is
False— no read, no write, no metric — and the shipped coordinator provider passessettings.enabledthrough. The provider reads it withgetattr(settings, "enabled", True)so asettings object written against
IdempotencySettingsProtocolbefore the field was part of itkeeps building a container; the protocol now declares it.
Tests:
test__coordinator__disabled__runs_the_action_without_touching_storage,test__shipped_providers__settings_disabled__coordinator_is_a_pass_through.7. Two sets of TTL defaults (breaking)
IdempotencyDomainServicedefaulted to 30 minutes / 60 s / 24 h;BaseIdempotencySettingsshipped 60 minutes / 1 s / 30 days. The effective bounds depended on which one built the
service.
core/constants.pyis now the single source and the settings model takes its field defaultsfrom it. I took the wider pair, deliberately: narrowing the ceiling to 24 hours would start
rejecting TTLs that work today, and
IdempotencyInvalidTTLErroris caught by the coordinator —those operations would have gone quietly uncached, which is the failure mode this whole round is
about. Nothing that works today stops working.
min_ttl_secondsmoves the other way, from 1 to 60, and no shipped path can observe it: thecoordinator floors every TTL at
max(1, ttl_seconds // 60)minutes, so it never asks for lessthan 60 seconds, and
create_recordtakes whole minutes.BREAKING CHANGE:DEFAULT_TTL_MINUTESis 60 (was 30) andMAX_TTL_SECONDSis 2592000 (was86400), so
IdempotencyDomainService()with no arguments keeps records for an hour and acceptsup to 30 days. Pass the arguments explicitly to keep the old numbers.
One existing test hard-coded 2000 minutes as "above the maximum"; it now derives the value from
MAX_TTL_SECONDS.Test:
test__domain_service_and_settings__agree_on_the_ttl_defaults.8.
validate_recordandIdempotencyRecordExpiredErrorhad no callerTrue, and it pointed at a real hole rather than dead code: the coordinator trusted the
repository never to hand back an expired record. The shipped Redis repository does check, but
expiry is the domain's rule and a backend without native expiry cannot enforce it.
Reproduction: a repository returning a record whose
expires_atwas an hour in the past madethe coordinator replay it forever — the action never ran.
The coordinator now calls
validate_recordon every record it reads and counts an expired oneas a miss. No change for the Redis backend, which already filtered them.
Test:
test__coordinator__expired_record_from_repository__is_a_miss.Docs
docs/agents.mdstated four of these defects as rules — the silent coordinator lookup, theinvisible positional key, the poison
null, and both layers counting — so those lines went withthe code, along with the TTL numbers, the coordinator signature, the settings field list and the
validate_recordnote.api_reference.md,user_guide.md,quickstart.mdandarchitecture.mdcarry the same changes.Verification
uv sync --frozenwas used throughout anduv.lockis unchanged.