This guide covers the auto-refresh feature in better-ccflare, which automatically sends a small probe message to an account when its usage window resets, to start the new window. It applies to anthropic, codex and zai accounts.
- Overview
- How Auto-Refresh Works
- Setting Up Auto-Refresh
- Configuration Examples
- Best Practices
- Troubleshooting
- API Reference
The auto-refresh feature automatically starts new usage windows by:
- Automatic Window Start: Sends a probe message when a usage window resets to start a new window
- New Window Initialization: Makes the first API call to begin the new rate limit window
- API Integration: Uses Anthropic's rate limit reset information for accurate timing
- Per-Account Control: Enable or disable auto-refresh on individual accounts
- Transparent Operation: Logs all refresh events for monitoring
- New Window Activation: Automatically starts the new usage window when the previous one expires
- Window Initialization: The first API call initializes the window's rate limit tracking
- Reduced Latency: No waiting for the first real request to start the window
- Intelligent Scheduling: Only starts new windows when the previous window has actually reset
- Window Monitoring: The system tracks the
rate_limit_resettimestamp from API responses - Reset Detection: Every minute, checks for accounts where
rate_limit_reset <= now(window has expired) - Window Expiration Check: For each candidate account, checks if the stored
rate_limit_resetfrom last refresh has expired - Account Selection: Only refreshes if the last refreshed window has expired or account was never refreshed
- Probe Message: One prompt is drawn from the probe pool and sent to start the new usage window
- Window Update: The NEW
rate_limit_resetfrom the API is stored (typically 5 hours in the future) - Repeat: Next refresh happens when that stored timestamp expires
The probe gate above (rate_limit_reset <= now) can deadlock if Anthropic resets an account's weekly window externally while the account still holds a stale, future rate_limit_reset from an earlier 429 — the scheduler never sees the window as expired, so it never probes the account to learn the real reset time. To break this, the usage poller separately watches for the contradiction (seven_day utilization reporting 0% with resets_at unknown, while rate_limit_reset is still in the future) and clears the stale rate_limit_reset so the account becomes eligible again on the next scheduler tick. The clear is a compare-and-swap guarded by the rate_limit_reset_at write-time column (see database.md), so a genuinely new rate limit recorded concurrently is never clobbered.
Auto-Refresh Scheduler (runs every minute)
↓
Query: Find accounts where
- auto_refresh_enabled = 1
- not paused (or paused by failure_threshold / auto-pause overage)
- provider IN ('anthropic', 'codex', 'zai')
- not awaiting reauthentication
- rate_limit_reset <= now (window has expired)
↓
For each candidate account (sequentially):
↓
Check: Has this account been refreshed before?
→ NO: Refresh it (first time)
→ YES: Check if stored rate_limit_reset <= now
→ YES: Window has expired, refresh it
→ NO: Window still active, skip it
↓
Claim a prompt from the 500-prompt pool (locked for 24h)
→ pool dry: send nothing, log when the first prompt frees up, stop here
↓
Send it through the proxy as a Claude Code CLI request (max_tokens: 10)
↓
Get NEW rate_limit_reset from API response (e.g., now + 5 hours)
↓
Update database: rate_limit_reset = new value
↓
Update tracking map: lastRefreshResetTime[account_id] = new value
↓
Next check: Will refresh again when new value expires (5 hours later)
Every refresh draws one prompt from a shared pool of 500 short questions
(packages/proxy/src/auto-refresh-prompt-pool.ts) and sends it with
max_tokens: 10, so the reply is capped and thrown away — the prompt itself is
the only real cost. The five prompts this feature originally rotated through
("What is 2+2?", "Tell me a programmer joke", …) are still in the pool, verbatim.
The pool has six rules, and each one is load-bearing:
-
500 distinct prompts, deliberately uneven in shape. Arithmetic, geography, shell and git one-liners, conversions, acronyms, one-word-back questions. 500 variants of "What is X?" would be as recognisable as one repeated string.
-
A drawn prompt is locked for 24 hours (
PROMPT_COOLDOWN_MS). A prompt sent now cannot be sent again — by this account or any other — until tomorrow. -
A claim is exclusive.
claimAutoRefreshPrompt()chooses a free index and stamps it before it returns, with noawaitin between. Two accounts refreshing in the same tick therefore cannot be handed the same text: the first one wins it and the second is given the next free prompt. "First one wins" is structural here, not luck. -
A claim that was never sent is given back. The claim happens before the scheduler knows whether it can send at all, so the paths that prove nothing left the process — no provider registered for the account, a throw before the request was issued — call
releaseAutoRefreshPrompt()and the prompt returns to circulation immediately. The lock is there to stop the same text being sent twice in a day; a claim that never became a request is not a lock, it is a leak. The mirror of that rule: once a request has gone out the prompt stays locked whatever came back, including an error, because the text was sent. -
A failure that is not counted still costs a wait, and the wait escalates. Two outcomes never reach the consecutive-failure counter on purpose: a 529 (overloaded or throttled — see the exemption below) and a request that produced no response at all. Neither pauses the account, so on their own they leave it eligible again on the very next 60s tick. Each of those re-probes spends a prompt that cannot be handed back, so an account stuck in that state would drain all 500 in a little over eight hours and stop every other account from refreshing.
So consecutive uncounted failures climb a ladder —
UNCOUNTED_FAILURE_BACKOFF_MS, which is 1m, 5m, 10m, 30m, 1h, 6h, 12h — and stay on the last rung. A ladder rather than one flat wait because the two ends want opposite things: a single blip should cost a minute, since the account is probably fine and the next window rollover should be caught quickly, while an account that has been failing all day should be asked twice a day. The first successful probe drops it straight back to the bottom rung, so a later blip costs a minute again instead of resuming the climb. The failures that are counted keep going through the five-strike pause threshold untouched, and the separateFAILURE_PROBE_COOLDOWN_MSthat throttles re-probes of already-paused accounts is untouched too.From the 1-hour rung up, the account also loses its place in the queue. The short rungs say nothing worth acting on, but an account the provider has been refusing for an hour or more is telling us something about live traffic as well, not just about probes. So the scheduler registers it in
PROBE_BACKOFF_PENALTY_THRESHOLD_MSterms (packages/core/src/probe-backoff.ts) and every load-balancer strategy sorts it behind accounts with no such history —compareAccountPreferencereplaces the bare priority comparison, andpreemptsOnPreferencestops a penalised account from stealing an active session from a healthy one. Within each group the configured priority still decides.It stays a penalty and never becomes a ban: a penalised account is still selected when it is the best remaining option, because the alternative is an install where everything is having a bad hour and there is nothing left to route to. The penalty expires with the rung that earned it, and the first successful probe clears it outright — an account is not made to serve out hours of a penalty it has already disproved. The registry is in memory only, like the prompt pool: what it holds is "how have the last few hours gone", which a restart is entitled to forget.
Rationing the rate is what bounds the total, because a claim is released after
PROMPT_COOLDOWN_MSrather than kept forever: what has to fit in the pool is not every probe ever sent but the ones alive inside one 24-hour lock window, which is rate x window. A chronically failing account settles at two probes a day, so it holds two prompts — and even its worst stretch, the climb from the bottom rung, is seven probes across the first twenty hours. The honest residual is therefore an order of magnitude away: it would take something like sixty accounts all entering that state at once to reach a dry pool, which is the condition the dry-pool alarm below exists to announce. -
A dry pool sends nothing. If every prompt is inside its cooldown, the claim fails and
sendDummyMessagereturnsfalsewithout sending, without touching the account row, and without counting a failure — holding off is not the account misbehaving, so it must not feed the consecutive-failure pause. The log names the moment the earliest-locked prompt frees up, once per episode rather than once per minute.
Draining the pool takes 500 refreshes inside 24 hours, and a healthy install does a couple of dozen. So the dry-pool branch is an alarm, not a routine state: it is the last line of defence if something ever puts the scheduler into a per-minute refresh loop. The uncounted-failure ladder above is the first one — it takes a single misbehaving account from sixty probes an hour down to two a day, so reaching a dry pool means something is looping that neither guard anticipated.
autoRefreshPromptPoolStatus() reports { free, total, retryAt } for
inspection.
Cooldown state is in memory only. What it protects is "did we send this in the last day"; a restart losing it costs a 1-in-500 chance of an early repeat, which does not justify a schema migration (and its PostgreSQL port).
The refresh probe is automated traffic dressed as a real Claude Code CLI request, and that disguise is intentional in three places:
- The prompt is a question a person might plausibly have typed — and it changes every time.
- The request wears the CLI's identity:
user-agent: claude-cli/<version> (external, cli), the wholex-stainless-*header set,anthropic-beta,anthropic-version. - It goes through the proxy (
localhost:<port>/v1/messageswithx-better-ccflare-account-id), not straight at the provider, so it travels the same path as real traffic.
Real accounts can be banned for automated/scripted usage. A fixed cheap string
on a timer — a single ".", or the same question every hour — is a perfect
robot fingerprint, which is why the cost of the varied prompt is accepted
instead of being optimised away.
Rejected, and recorded so it is not re-proposed: making the scheduler send
the manual refresh button's minimal "." ping for Codex accounts. It was
implemented and reverted. It saves a handful of input tokens per refresh and
pays for them with the only camouflage the probe has. The button is a different
case and keeps its ".": it fires only when a human clicks it, so it has no
cadence to fingerprint.
Since the Codex usage poller reads the free chatgpt.com/backend-api/wham/usage
endpoint (see docs/providers.md, CodexProvider feature 8), the refresh button
only falls back to the "." ping when that endpoint returns nothing — an
account on a custom endpoint, or a 403.
- Supported Provider: The scheduler only considers
anthropic,codexandzaiaccounts. The probe is always an Anthropic-shaped request sent through the proxy, which routes it to the forced account and translates the model name for the other two. - Valid Token: Account must have a valid access token
- API Access: Server must be running to enable auto-refresh
The easiest way to enable auto-refresh is through the web dashboard:
- Navigate to http://localhost:8080 (or your configured port)
- Go to the "Accounts" tab
- Find the account you want to enable auto-refresh for
- Toggle the "Auto-refresh" switch next to the account name
- The toggle will be enabled immediately
# Get account ID
ACCOUNT_ID=$(curl -s http://localhost:8080/api/accounts | jq -r '.[] | select(.name=="my-account") | .id')
# Enable auto-refresh
curl -X POST http://localhost:8080/api/accounts/$ACCOUNT_ID/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'# Check auto-refresh status
curl -s http://localhost:8080/api/accounts | \
jq '.[] | {name, autoRefreshEnabled, rateLimitReset}'
# Monitor logs
tail -f ~/.local/share/better-ccflare/logs/better-ccflare.log | grep "Auto-refresh"Setup to keep your primary account always refreshed:
# Get primary account ID
PRIMARY_ID=$(curl -s http://localhost:8080/api/accounts | jq -r '.[] | select(.name=="primary") | .id')
# Enable auto-refresh on primary account
curl -X POST http://localhost:8080/api/accounts/$PRIMARY_ID/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'Behavior:
- New usage window starts immediately when previous window resets
- First API call made automatically to initialize the window
- Minimal delay when switching to this account
Use both auto-refresh and auto-fallback for optimal availability:
# Get account ID
ACCOUNT_ID=$(curl -s http://localhost:8080/api/accounts | jq -r '.[] | select(.name=="premium") | .id')
# Enable both auto-refresh and auto-fallback
curl -X POST http://localhost:8080/api/accounts/$ACCOUNT_ID/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'
curl -X POST http://localhost:8080/api/accounts/$ACCOUNT_ID/auto-fallback \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'Behavior:
- Auto-fallback switches back to this account when window resets
- Auto-refresh immediately starts the new window with a dummy message
- Seamless transition with the window already initialized
Enable auto-refresh only on high-priority accounts:
# Enable on accounts with priority < 10
for account in $(curl -s http://localhost:8080/api/accounts | jq -r '.[] | select(.priority < 10) | .id'); do
curl -X POST http://localhost:8080/api/accounts/$account/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'
doneBehavior:
- Only high-priority accounts are auto-refreshed
- Lower priority accounts save costs by not refreshing automatically
- Focus refresh activity on important accounts
- Enable on Critical Accounts: Use auto-refresh for accounts that need their windows started immediately
- Consider Costs: Each refresh uses a small number of tokens (10 tokens per window start)
- Monitor Usage: Track refresh frequency in logs
# Monitor auto-refresh events in real-time
tail -f ~/.local/share/better-ccflare/logs/better-ccflare.log | grep "Auto-refresh"
# Check refresh status
watch -n 30 'curl -s http://localhost:8080/api/accounts | jq ".[] | select(.autoRefreshEnabled == true)"'
# Set up alerts for refresh failures
# (Example: Send notification when refresh fails)- Selective Enablement: Only enable on accounts where immediate availability matters
- Combine with Auto-Fallback: Use together for optimal account switching
- Monitor Refresh Frequency: Each refresh consumes a small number of tokens
- Test in Development: Verify auto-refresh behavior before production use
- Monitor Logs: Watch for any unexpected refresh failures
- Custom Endpoints: Works with custom endpoint configurations
Symptoms:
- Account not being refreshed when window resets
- No refresh events in logs
Solutions:
# Check if auto-refresh is enabled
curl -s http://localhost:8080/api/accounts | jq '.[] | {name, autoRefreshEnabled, rateLimitReset}'
# Verify account is Anthropic provider
curl -s http://localhost:8080/api/accounts | jq '.[] | {name, provider, autoRefreshEnabled}'
# Check if account is paused
curl -s http://localhost:8080/api/accounts | jq '.[] | {name, paused, autoRefreshEnabled}'Symptoms:
- Refresh messages failing with errors
- Error messages in logs
Solutions:
- Check access token validity
- Verify custom endpoint is correct (if configured)
- Ensure account has not hit rate limits
- Check network connectivity
Symptoms:
- A warning naming the time the first prompt frees up, and nothing being sent
What it means:
This takes 500 refreshes inside one day, so it is almost never the pool's fault
— it means something is asking for refreshes in a loop. The usual cause is a
rate_limit_reset stuck in the past (the scheduler then sees "window reset"
every minute), so check the account's stored reset first:
sqlite3 ~/.config/better-ccflare/better-ccflare.db \
"SELECT name, provider, datetime(rate_limit_reset/1000,'unixepoch') FROM accounts WHERE auto_refresh_enabled = 1;"Nothing is sent while the pool is dry, and the accounts are not penalised for it — the refusal is not counted as a refresh failure.
Symptoms:
- Excessive refresh activity
- Too many refresh events in logs
Solutions:
- Verify
rate_limit_resettimestamp is correct - Check for clock synchronization issues
- Reduce number of accounts with auto-refresh enabled
Enable debug logging to troubleshoot issues:
# Set debug environment variable
export LOG_LEVEL=DEBUG
# Restart server
better-ccflare
# Monitor detailed logs
tail -f ~/.local/share/better-ccflare/logs/better-ccflare.log | grep -E "(Auto-refresh|AutoRefreshScheduler)"Monitor system health with these endpoints:
# Check overall system health
curl http://localhost:8080/health
# Get statistics
curl http://localhost:8080/api/stats
# List all accounts with detailed status
curl http://localhost:8080/api/accounts# Enable auto-refresh
curl -X POST http://localhost:8080/api/accounts/{account-id}/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'
# Disable auto-refresh
curl -X POST http://localhost:8080/api/accounts/{account-id}/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 0}'# List all accounts
curl http://localhost:8080/api/accounts
# Get specific account
curl http://localhost:8080/api/accounts/{account-id}Account response includes auto-refresh status:
{
"id": "account-uuid",
"name": "my-account",
"provider": "anthropic",
"autoRefreshEnabled": true,
"rateLimitStatus": "OK",
"rateLimitReset": "2024-12-17T11:00:00.000Z",
"paused": false
}Auto-refresh events are logged with these patterns:
[INFO] Starting auto-refresh scheduler
[INFO] Found 2 account(s) with reset windows for auto-refresh
[INFO] Sending auto-refresh message to account: my-account (prompt #137)
[INFO] Auto-refresh message sent successfully for account: my-account
[INFO] Updated rate_limit_reset for my-account to 2024-12-17T11:00:00.000Z
[ERROR] Auto-refresh message failed for account my-account: 429 Too Many Requests
[WARN] Auto-refresh is sending nothing for my-account: all 500 probe prompts are inside their 24h cooldown, ... The first prompt frees up at 2024-12-18T09:12:00.000Z.
[DEBUG] Auto-refresh still holding off on my-account until 2024-12-18T09:12:00.000Z — every probe prompt is on cooldown
The prompt #N index is the pool position, not the prompt text: the log stays
readable and the probe's content stays out of it.
The auto-refresh scheduler runs every minute by default. This is configured in the AutoRefreshScheduler class:
private checkInterval = 60000; // Check every minuteAuto-refresh works with custom endpoint configurations:
# Set custom endpoint for account
curl -X POST http://localhost:8080/api/accounts/$ACCOUNT_ID/custom-endpoint \
-H "Content-Type: application/json" \
-d '{"customEndpoint": "https://custom.api.endpoint/v1/messages"}'
# Enable auto-refresh (will use custom endpoint)
curl -X POST http://localhost:8080/api/accounts/$ACCOUNT_ID/auto-refresh \
-H "Content-Type: application/json" \
-d '{"enabled": 1}'Set up monitoring for auto-refresh events:
# Script to monitor and alert on auto-refresh
#!/bin/bash
while true; do
if tail -n 10 ~/.local/share/better-ccflare/logs/better-ccflare.log | grep -q "Auto-refresh.*failed"; then
echo "⚠️ Auto-refresh failed at $(date)"
# Send notification (email, Slack, etc.)
fi
sleep 60
doneThe auto-refresh feature automatically starts new usage windows for your Anthropic accounts when their rate limit windows reset. By carefully configuring which accounts have auto-refresh enabled, you can achieve:
- Automatic Window Initialization when usage windows reset
- Reduced Latency by pre-starting windows before real requests
- Window Tracking ensuring rate limit windows are properly initialized
- Optimal Resource Usage through selective enablement
For questions or issues, refer to the troubleshooting section or check the main documentation index.