NEB-898 Report Xero's rate limits on every response, not only on refusal - #85
Merged
abreckner merged 2 commits intoAug 27, 2026
Conversation
Xero returns its rate-limit headers on every XPM response. The 429 branch was the only place that read them, so the gem could only ever tell a caller that a budget was already gone -- which is too late to pace against. XPM's daily budget is the limit that refuses us in production, and a caller that wants to stop short of it needs the remaining count while its requests are still succeeding. `XpmRuby::RateLimits` parses the header set off any response, and `XpmRuby.on_rate_limits` is handed one whenever a response reports a budget. A module-level callback rather than a reader on the connection: every entry point here builds its own Connection and never hands it back, so a reader would be unreachable from a caller that only calls `Client.get`. The callback also reaches the 429s that raise from inside a rescue somewhere, which an exception-carried payload does not. Counts parse through `Integer(..., exception: false)`, so an absent header stays absent. `to_i` would read it as 0, and for a remaining-budget count that is not "unknown" but "exhausted" -- a caller gating on it would refuse every request off a response that never mentioned a budget at all. A callback that raises cannot fail the request it was measuring: a caller whose store is down would otherwise take every XPM call with it. The error is warned rather than swallowed, because silent is indistinguishable from a callback nobody wired up. `RateLimitExceeded#details` keeps the same wire-named hash, now read through `[]` rather than `slice`. Only `[]` is case-insensitive on a Faraday::Utils::Headers, so a `Retry-After` would have left `details` empty and a caller reading it would have fallen back silently, as though Xero had never named a delay. Xero sends these lowercase -- HTTP/2 requires it -- so for today's traffic the hash is unchanged, down to the key order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recorded traffic splits the header set in two, and the specs and README were treating it as one. A 200 carries the three remaining counts and nothing else (spec/vcr_cassettes/xpm_ruby/connection/delete.yml: min 59, day 4984, appmin 9999). `retry-after` and `x-rate-limit-problem` arrive only on a 429 — Xero names a delay and a cause only when it refuses. The success spec asserted `x-rate-limit-problem` on a 200, which no XPM response sends, so it pinned a shape the API does not produce. It now uses the recorded values, and documents that `problem` and `retry_after` are absent rather than "no problem" on a successful call. No production behaviour changes: the reader already parsed each header independently, so a 200 without the two 429-only headers was always read as nil. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
/auto-approve |
abreckner
deleted the
tonybreckner/neb-898-surface-rate-limit-headers-on-success
branch
August 27, 2026 04:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent (What)
Report Xero's remaining rate-limit budget on every XPM response, not only on the
429that refuses one.XpmRuby::RateLimitsparses the header set off any response, and a new module-level callback is handed one whenever a response reports a budget:This is the gem half of NEB-898 — its item 1. Nothing in the monorepo reads the callback yet; the pre-flight guard that will (items 2–5) is monorepo work and needs a release and a pin bump first.
What XPM actually sends — from the recorded traffic in this repo, which is the evidence the ticket was missing:
429x-minlimit-remaining,x-daylimit-remaining,x-appminlimit-remainingretry-after,x-rate-limit-problemA 200 in
spec/vcr_cassettes/xpm_ruby/connection/delete.ymlcarriesx-minlimit-remaining: 59,x-daylimit-remaining: 4984,x-appminlimit-remaining: 9999. So the remaining counts were there on the happy path all along and the gem dropped them — that is the gap being closed. Xero names a delay and a cause only when it refuses, soproblemandretry_afterare legitimately absent on a success and must not be read as "no problem".Motivation (Why)
XPM has no model of Xero's daily request budget, and this gem is why it cannot have one. The
429branch was the only place reading the headers, so the gem could only ever report that a budget was already gone — too late for a caller trying to pace itself under the limit.That daily budget is the limit actually refusing us.
sum:ignitionapp.xpm.rate_limit.refused{*} by {problem}, full 14 days to 27 Aug: 61,910problem:day, and zerominute,concurrentorappminute—dayis the only group the metric has ever emitted. Episodic rather than steady state (all of it 18–21 Aug, bulk client imports on two tenants), but any tenant importing in bulk burns its whole day budget and then syncs nothing until the window resets.It also blocks knowing the cap at all: on a
429x-daylimit-remainingis always0, so the number a threshold would be sized against can only come from a successful response. (The cassette's4984hints at a ~5,000/day cap, but those recordings are old — size the threshold from fresh production data once this is wired up, not from that.)Implementation (How)
Connectionand never hands it back, soClient.getcallers could never reach an instance reader — the same unreachable-by-construction trap as therespond_to?(:retry_after)guard documented inxpm_integration. The callback also reaches the429s that raise from inside someone else'srescue, which an exception-carried payload does not.case, so a budget is read off responses that raise as well as those that return. Skipped when a response reports nothing at all — that is not the same as a budget of zero, and a caller should not have to tell the two apart from an object of nils.Integer(..., exception: false).to_iwould read a missing or unparseable header as0, which for a remaining-budget count is not "unknown" but "exhausted" — a guard built on it would refuse every request off a response that never mentioned a budget.Consequence
RateLimitExceeded#detailsis unchanged for today's traffic, so the three monorepo consumers (workers/retry_metrics.rb:140,workers/honours_retry_after.rb:52and:94) keep reading it as they do. The existing429spec asserts the same hash it always did.One fix beyond the ticket, easy to drop if unwanted:
detailsnow reads through[]rather thanslice. Only[]is case-insensitive on aFaraday::Utils::Headers:So a
Retry-Afterfrom Xero would have leftdetailsempty, andWorkers::HonoursRetryAfterwould have fallen back to its fixed curve with nothing to show it had. Xero sends these lowercase — HTTP/2 requires it — which is why this has never bitten; the hash is byte-identical on current traffic, key order included, and a spec now pins both casings.Behaviour is unchanged for any caller that does not set
on_rate_limits.Verification
bundle exec rake— 111 examples, 0 failures (93 before).bundle exec rubocop— 48 files, no offences. CI green on Ruby 3.1.Version bumped 0.5.0 → 0.6.0.
api/Gemfilein the monorepo pins"0.5.0"exactly and its lockfile pins a revision, so nothing there moves until someone bumps it deliberately.🤖 Generated with Claude Code