Runbook: 1Password rate-limit hit¶
Symptom¶
One or more of:
- ESO ExternalSecret resources stuck in
SyncedFalse, with status messages like "rate limited" or "Too many requests". - An ansible-playbook run fails partway through with
op readreturningToo many requeststo stderr. - A cron-driven script (Prometheus target sync, vault-pass.sh, the bridge token refresh) starts erroring at random points in the day.
- The 1Password quota Grafana panel suddenly shows
read_write USED: 1000 / 1000, REMAINING 0. - Discord goes quiet on the
#opschannel becausediscord-alert-proxycannot pull its webhook URL secret to fan out.
What you are actually hitting¶
1Password service accounts have two independent rate limits, and confusing them is the #1 reason I wasted time during the 2026-04-18 incident:
| Limit | Period | Scope | Personal / Families value |
|---|---|---|---|
| Per-token read | 1 hour | per service account token | 1,000 |
| Per-token write | 1 hour | per service account token | 100 |
| Account read+write | 24 hours | per 1Password account, all SAs combined | 1,000 |
The big one for me is the daily account cap. Creating a second service account does not double your daily quota. The bucket is the account.
Triage¶
Always start with the live status:
This is a free control-plane call. It does not consume quota and works even when you are at 1000/1000. Sample output during the 2026-04-18 incident:
TYPE ACTION LIMIT USED REMAINING RESET
token write 100 0 100 N/A
token read 1000 0 1000 N/A
account read_write 1000 1000 0 5 hours from now
The "RESET" column tells you when the 24-hour window rolls over and frees the quota (on this account, about 02:51 UTC). The CLI's other error messages (Try again in seconds) truncate the number; do not rely on them.
Then find who is spending it. Run everything below on command-center1.
1. Attribution. op calls made through the logging wrapper are counted per consumer:
# Top consumers, last hour
curl -s 'http://192.168.1.230:9090/api/v1/query' \
--data-urlencode 'query=topk(10, sum by (consumer, subcommand) (increase(onepassword_op_invocations_total[1h])))' | jq .
# Raw records, newest last
tail -n 50 /var/log/op-shim/op-invocations.log | jq -c '{ts, consumer, chain, subcommand}'
2. Burst not explained by attribution? Reconcile against the account and token counters, then measure one suspect operation against the free status call:
before=$(op service-account ratelimit | awk '/account/{print $4}')
ssh command-center1 true # or: one playbook task, one script, one sync
after=$(op service-account ratelimit | awk '/account/{print $4}')
echo "cost: $((after - before)) reads" # expect 0
A clean strace -f or process list does not clear SSH-driven work: shells that sshd starts are outside the caller's process tree, even on the same host (2026-09-12).
3. Other places to look:
# ESO controller, recent reconcile activity
kubectl -n external-secrets logs -l app.kubernetes.io/name=external-secrets --tail=200 \
| grep -iE "1password|onepassword|rate"
# Long-running callers, names only (a snapshot: misses short calls)
ps -eo pid,ppid,comm --forest | grep -E "op|ansible|run-" | grep -v grep
# Shell startup files must never call op: every SSH command would pay for it.
# Lists matching files only, and ignores comment lines.
grep -lE '^[^#]*\bop[[:space:]]+(read|inject|item)\b' ~/.bashrc ~/.profile /etc/profile.d/*.sh 2>/dev/null
# Quota trend (or the Grafana 1P quota dashboard)
curl -s 'http://192.168.1.230:9090/api/v1/query?query=onepassword_ratelimit_remaining' | jq .
Stop the bleed¶
Do all three of these in order, even if you think you know which one is at fault. The retry loops compound and the daily window only resets once.
# 1. Scale ESO to zero. Its retry loop fires every ~6 min when a sync fails
# and is NOT configurable. This is the single biggest amplifier when the
# cap has already been hit.
kubectl -n external-secrets scale deploy external-secrets --replicas=0
kubectl -n external-secrets scale deploy external-secrets-cert-controller --replicas=0
kubectl -n external-secrets scale deploy external-secrets-webhook --replicas=0
# 2. Disable the ansible timers on command-center1.
ssh command-center1 'sudo systemctl stop ansible-proxmox.timer ansible-security.timer'
ssh command-center1 'sudo systemctl disable --now ansible-proxmox.timer ansible-security.timer'
# 3. Trip the local kill-switch so any straggler script bails fast.
ssh command-center1 'scripts/op-killswitch-status.sh trip'
Wait it out¶
The 24h account cap is the long pole. From RESET: N hours from now, that is when it lifts. Do not poll. Do not "just check if it cleared yet." Every probe call was free above the limit, but every actual op read you trigger spends quota you do not have.
After the cap recovers:
# Verify
op service-account ratelimit
# Clear the kill-switch
ssh command-center1 'scripts/op-killswitch-status.sh clear'
# Bring ESO back
kubectl -n external-secrets scale deploy external-secrets --replicas=1
kubectl -n external-secrets scale deploy external-secrets-cert-controller --replicas=1
kubectl -n external-secrets scale deploy external-secrets-webhook --replicas=1
# Force a sync on the watchdog secret first; if that succeeds the rest will follow.
kubectl annotate externalsecret <name> -n <ns> force-sync=$(date +%s) --overwrite
The kill switch clears itself once a free quota read shows more than 200 remaining. That does not restart timers you disabled in "Stop the bleed". Once attribution shows no unexpected consumer, re-enable them explicitly:
ssh command-center1 'sudo systemctl enable --now ansible-proxmox.timer ansible-security.timer'
ssh command-center1 'systemctl list-timers "ansible-*"'
Why creating another service account does not help¶
This is the single most common wrong intuition during the incident. New SAs get a fresh hourly budget but share the daily account budget. I tried this on 2026-04-18, created eso-op-retrieval, swapped ESO to it, and its very first op vault list call returned "Too many requests." The bucket is the account.
The only way to raise the daily ceiling is to upgrade the 1Password tier:
| Tier | Daily account cap |
|---|---|
| Personal / Families | 1,000 |
| Teams | 5,000 |
| Business | 50,000 |
Long-term mitigations already in place¶
- Kill switch at
/var/lib/ansible-quasarlab/1p-killswitch. All ansible wrappers honor it. It trips when a wrapper finds "Too many requests" anywhere in a run's captured output (which can false-trip on--difftext, see ansible-quasarlab#160), and clears once a free quota read shows more than 200 remaining (24h at most). - Secret cache: restricted local files, 48h TTL, locked against concurrent misses, populated once per playbook run via
scripts/lib/op-secret-cache.sh. - Attribution wrapper on command-center1 exports
onepassword_op_invocations_total{consumer,caller,unit,subcommand}. - Shell startup files:
/etc/profile.d/op-ansible-env.shis templated withoutop, and a task fails the play ifop readreappears in~/.bashrc. - ESO refresh interval bumped from 1h to 24h on every ExternalSecret. The retry loop is unaffected by this and is the unsolved part.
- Quota collector running on
command-center1(op-quota-collector.timer, every 5 min, exportsonepassword_ratelimit_*gauges). Alerts at 50% (warning) / 80% / 95% of the daily cap, plus a burn-rate alert on reads per hour. - Operational rule for Claude: one
opcall per session, only when explicitly requested by the user. Never loop or re-probe.
Outstanding work¶
The dynamic inventory bypass was fixed in ansible-quasarlab#129 (measured at 0 reads on 2026-09-13), and the shell startup read in 2026-09-12. Still open: the source of the 2026-09-12 bursts, and the kill switch output scan false-tripping on --diff text (ansible-quasarlab#160).
Patterns worth knowing¶
- Two limits, two scopes. Hourly is per-token; daily is per-account. Confusing them costs hours.
op service-account ratelimitis free. It is the one call you can always make. Use it before doing anything else.- The retry loop is the amplifier. ESO and any ansible re-fork can burn a clean budget in under an hour. Stop the retries, then wait.
- Don't probe with real reads to "see if it cleared." Every
op readspends quota you do not have. The status callop service-account ratelimitis free, so use that instead.