Skip to content

fix(backends): require an int ttl and delta before any backend I/O - #262

Merged
allen0099 merged 1 commit into
masterfrom
fix/validate-ttl-229
Sep 26, 2026
Merged

allen0099 merged 1 commit into
masterfrom
fix/validate-ttl-229

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #229.

Problem

validate_ttl() only rejected values <= 0, so non-integer TTLs slipped through and each backend handled them differently:

  • True was taken as one second.
  • 1.5 worked on the memory backend only.
  • 10**400 passed validation.

The worst case, reproduced against a live Redis: increment("c", ttl=1.5) on a missing key ran INCRBY before EXPIRE failed. Redis does not roll back a failed script, so the counter stayed at 1 with no TTL (TTL = -1), a permanent lockout for a rate limiter. The error said the key was "not a counter", because the Redis error text ("value is not an integer or out of range") matched the mapping for a non-counter value. Memcached gave the same misleading message.

While reproducing this I found three related problems, also fixed here:

  • Memcached silently drops items whose expiry is past 2038-01-19. Its exptime is a signed 32-bit timestamp. Verified on memcached 1.6: set(ttl=11 years) is stored, while set(ttl=12 years) succeeds and the item is already gone.
  • An out-of-range or float delta (e.g. 2**64, 1.5) also reached the server and was reported as "not a counter" on both Redis and Memcached.
  • @cache(ttl=1.5) would fail in the backend on every request, and fix(cache): serve uncached instead of 500 when the backend fails #259's fail-open would hide that the route never caches.

Fix

  • validate_ttl accepts None or an int from 1 to the new MAX_TTL = 2**31 - 1 (about 68 years).
    • float, bool and other types raise TypeError.
    • Out-of-range values raise ValueError.
    • Because it runs before any I/O on every backend, the Redis script can no longer fail after INCRBY.
  • New validate_delta requires an int in the signed 64-bit range. It is called by increment on every backend and the base fallback.
  • Memcached:
    • _expiry() raises ValueError for an expiry past 2038-01-19. increment converts the TTL before any I/O.
    • Only MemcacheClientErrors about non-numeric values map to "not a counter"; other client errors propagate unchanged.
  • @cache raises CacheXError at decoration time for a non-int ttl or one above MAX_TTL, next to the existing negative check.

timedelta is not accepted. That would widen every ttl: int | None signature, so the docs say to use int(td.total_seconds()).

Compatibility

A float TTL that used to work on the memory backend now raises TypeError. On Redis and Memcached it already failed. Internal callers already pass int: the session manager's TTL is int(...).

Tests

  • tests/backends/test_ttl_contract.py: the bad-TTL table now covers float, integral float, bool, str, MAX_TTL + 1 and 10**400 (besides 0 and -1) on every backend, CacheManager and StateManager, all before any I/O. New test_backends_reject_invalid_delta.
  • Live Redis: test_redis_increment_with_a_float_ttl_leaves_no_counter.
  • Memcached, stubbed:
    • test_expiry_up_to_2038_is_sent_as_a_timestamp (boundary);
    • test_ttl_past_2038_is_rejected_before_io[set|set_if_absent|increment];
    • test_memcached_increment_only_maps_non_numeric_errors_to_not_a_counter.
  • tests/test_cache.py: test_invalid_ttl_is_rejected_at_decoration[float|bool|too-large].

Mutation checks, run with the old backends plus only the MAX_TTL constant:

  • 37 new or extended cases fail and every existing test passes.
  • Removing the Memcached non-numeric guard fails only its test.
  • Reverting the @cache check fails the three new decoration cases.

The full suite passes against live Redis and Memcached: 983 passed, 99.96% coverage. Both docs builds pass with --strict.

Docs

  • BACKENDS.md (EN and zh-TW):
    • the "TTL values" section is rewritten as three rules;
    • the increment bullet documents delta;
    • the Memcached limitations now include the 2038 limit.
  • HTTP_CACHING.md (EN and zh-TW): the decoration-time check.
  • CHANGELOG: ### Security entry.
  • CLAUDE.md: one line.

validate_ttl accepted floats and bools. On Redis, increment(ttl=1.5) created
the counter before EXPIRE failed, leaving a counter that never expires behind
a misleading "not a counter" error. Only an int up to MAX_TTL (2**31 - 1) is
accepted now, and increment checks delta the same way. Memcached rejects
expiries past 2038-01-19, which it used to drop silently, and maps only
non-numeric values to "not a counter". @cache checks ttl at decoration time.

Closes #229
@allen0099 allen0099 added this to the 0.3.8 milestone Sep 26, 2026
@allen0099 allen0099 added bug Something isn't working backends Cache backends and their atomic primitives labels Sep 26, 2026
@allen0099
allen0099 merged commit 1d2bd73 into master Sep 26, 2026
11 checks passed
@allen0099
allen0099 deleted the fix/validate-ttl-229 branch September 26, 2026 22:16
@allen0099 allen0099 added the security Security vulnerability or hardening label Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backends Cache backends and their atomic primitives bug Something isn't working security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validate_ttl accepts bool and float; on Redis increment a float TTL leaves a counter that never expires

1 participant