Skip to content

bug(SUB-003): rate-limit events never age out due to SQLite string-compare bug; retries amplify outages #476

Description

@vybe

Summary

auto_switch_subscriptions effectively marks alternative subscriptions as rate-limited for the entire UTC day after a single 429, then fails to find viable alternatives for hours. The surface symptom is "slow ping-pong through exhausted subscriptions"; the actual root cause is a broken string comparison in SQLite — rate-limit events never age out within the same UTC day, regardless of when they occurred.

A secondary problem compounds this: scheduled executions that fail during the outage generate pending_retry records that all become due around the same time.

Component

Backend / db/subscriptions.py + src/scheduler/service.py

Priority

P1

Root Cause

Bug 1 — SQLite lexicographic comparison across formats (the real bug)

utils/helpers.py:43 stores timestamps in ISO-8601 with T separator and Z suffix:

datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
# → "2026-04-23T08:01:12.123456Z"

SQLite's datetime('now', '-2 hours') returns:

"2026-04-23 06:01:12"   ← space separator, no Z

The "last 2 hours" filter in db/subscriptions.py:503, 515, 535 compares these lexicographically:

WHERE occurred_at > datetime('now', '-2 hours')

At position 10, T (0x54) > space (0x20), so the comparison returns true as soon as the date prefix matches — the rest of the timestamp is never evaluated. Every event with today's date passes the "last 2 hours" check, regardless of actual clock time.

Bug 2 — Events are never cleared on successful execution

clear_rate_limit_events(agent, subscription) exists in db/subscriptions.py:519 but has zero callers in the codebase. The only cleanup path is cleanup_old_rate_limit_events() (24h), which has the same lexicographic bug.

Net effect

A subscription that gets a single rate-limit event at 00:05 UTC will be treated as rate-limited until 23:59:59 UTC of the same day (when the date prefix rolls over). With multiple subscriptions accumulating one event each during the day, select_best_alternative_subscription() runs out of candidates within minutes of the first real outage, not 2 hours.

This explains the observed symptom: "no viable alternative subscription found" appearing persistently rather than every 2 hours as the 2h window design would suggest.

Bug 3 — Default max_retries=1 for schedules generates unnecessary retry load

db_models.py:120 defaults max_retries = 1 per schedule. During a multi-hour subscription outage, every failed scheduled execution generates a pending_retry record. _recover_pending_retries() at src/scheduler/service.py:1260 then fires all overdue retries with retry_at = now + timedelta(seconds=5) on scheduler restart — all within 5 seconds of each other.

For the typical Trinity use case (agents are stateful/idempotent; the next cron tick catches up), retries add no value and amplify load during outages.

Fix

Three small, independent changes. No schema change. No error-body parsing. No rate_limited_until column.

Change 1 — Fix the string comparison

Compute the cutoff in Python in the same format as stored values, pass as parameter:

# db/subscriptions.py
from datetime import datetime, timezone, timedelta

def _cutoff_iso(hours: int) -> str:
    """Compute a cutoff timestamp in the same format as utc_now_iso()."""
    return (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime('%Y-%m-%dT%H:%M:%S.%fZ')

Update three query sites:

# record_rate_limit_event (line 499-504)
cursor.execute("""
    SELECT COUNT(*) as cnt
    FROM subscription_rate_limit_events
    WHERE agent_name = ? AND subscription_id = ?
      AND occurred_at > ?
""", (agent_name, subscription_id, _cutoff_iso(2)))

# is_subscription_rate_limited (line 511-516)
cursor.execute("""
    SELECT COUNT(*) as cnt
    FROM subscription_rate_limit_events
    WHERE subscription_id = ?
      AND occurred_at > ?
""", (subscription_id, _cutoff_iso(2)))

# cleanup_old_rate_limit_events (line 533-536)
cursor.execute("""
    DELETE FROM subscription_rate_limit_events
    WHERE occurred_at < ?
""", (_cutoff_iso(24),))

After this, the 2h window actually works. A 2h ping-pong between genuinely rate-limited subscriptions is acceptable — it's not worth parsing Anthropic's error message format (fragile, Anthropic can change it anytime) to avoid.

Change 2 — Default max_retries=0

src/backend/db_models.py:120, 147 and src/scheduler/models.py:52:

max_retries: int = 0  # 0 = disabled (default). 1-5 opt-in for schedules that truly need retry.

Update the comment and corresponding frontend/API docs. Schedules that explicitly set a value are unaffected.

Rationale: Scheduled agents are expected to catch up on the next tick. Retrying a failed run rarely adds value and generates unnecessary load during outages. This is opt-in, not opt-out.

Change 3 — Consistency: ensure cleanup_old_rate_limit_events() runs

Verify it's scheduled (e.g. via maintenance service). If not, wire it into an existing cleanup path — without it, the table grows unbounded once Change 1 is in.

Deliberately out of scope

These were considered and rejected:

  • subscription_credentials.rate_limited_until column + 429-body parsing. Fragile — parses a human-readable error string from Anthropic that's not part of any API contract. Timezone parsing edge cases. Silent failure modes. The simpler fix (Change 1) delivers the same net behavior: subscriptions that are actually rate-limited stay rate-limited for 2 hours.
  • Thundering-herd / staggered retry replay. Unnecessary once max_retries=0 is the default. If a user explicitly opts into retries for a specific schedule, the existing 2x-delay-on-429 behavior is adequate.
  • Calling clear_rate_limit_events() on successful execution. Not needed. The 2h window (now working) is the correct signal. A subscription that succeeds naturally stops accumulating events.

Verification

  1. Unit test for _cutoff_iso() — confirm format matches utc_now_iso().
  2. Integration test — insert an event with occurred_at 3 hours ago (via _cutoff_iso(3)), run is_subscription_rate_limited(), assert False. Insert an event 1 hour ago, assert True.
  3. Manual check — on a db with existing rate-limit events from earlier today, verify SELECT COUNT(*) FROM subscription_rate_limit_events WHERE occurred_at > ? (with the Python cutoff) now returns ~0 instead of all events from today.
  4. Retry default — create a new schedule via UI/API; confirm max_retries stored as 0.

Environment

  • Trinity version: f72b153
  • Affects all instances using subscription_type = max with auto_switch_subscriptions = true (Bug 1+2)
  • Affects all instances with scheduled agents (Bug 3)

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions