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
- Unit test for
_cutoff_iso() — confirm format matches utc_now_iso().
- 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.
- 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.
- 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
Summary
auto_switch_subscriptionseffectively 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_retryrecords that all become due around the same time.Component
Backend /
db/subscriptions.py+src/scheduler/service.pyPriority
P1
Root Cause
Bug 1 — SQLite lexicographic comparison across formats (the real bug)
utils/helpers.py:43stores timestamps in ISO-8601 withTseparator andZsuffix:SQLite's
datetime('now', '-2 hours')returns:The "last 2 hours" filter in
db/subscriptions.py:503, 515, 535compares these lexicographically:At position 10,
T(0x54) > space (0x20), so the comparison returnstrueas 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 indb/subscriptions.py:519but has zero callers in the codebase. The only cleanup path iscleanup_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=1for schedules generates unnecessary retry loaddb_models.py:120defaultsmax_retries = 1per schedule. During a multi-hour subscription outage, every failed scheduled execution generates apending_retryrecord._recover_pending_retries()atsrc/scheduler/service.py:1260then fires all overdue retries withretry_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_untilcolumn.Change 1 — Fix the string comparison
Compute the cutoff in Python in the same format as stored values, pass as parameter:
Update three query sites:
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=0src/backend/db_models.py:120, 147andsrc/scheduler/models.py:52: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()runsVerify 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_untilcolumn + 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.max_retries=0is the default. If a user explicitly opts into retries for a specific schedule, the existing 2x-delay-on-429 behavior is adequate.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
_cutoff_iso()— confirm format matchesutc_now_iso().occurred_at3 hours ago (via_cutoff_iso(3)), runis_subscription_rate_limited(), assertFalse. Insert an event 1 hour ago, assertTrue.SELECT COUNT(*) FROM subscription_rate_limit_events WHERE occurred_at > ?(with the Python cutoff) now returns ~0 instead of all events from today.max_retriesstored as 0.Environment
f72b153subscription_type = maxwithauto_switch_subscriptions = true(Bug 1+2)Related
f72b153)db/subscriptions.py:474-505, 507-517, 529-539— three query sitesutils/helpers.py:43—utc_now_iso()format that created the mismatchdb_models.py:120, 147,src/scheduler/models.py:52—max_retriesdefault