xero-kiwi 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/docs/retries-and-rate-limits.md +53 -0
- data/docs/throttling.md +14 -4
- data/lib/xero_kiwi/client.rb +2 -1
- data/lib/xero_kiwi/errors.rb +8 -2
- data/lib/xero_kiwi/throttle/redis_token_bucket.rb +14 -7
- data/lib/xero_kiwi/throttle.rb +17 -3
- data/lib/xero_kiwi/version.rb +1 -1
- data/llms-full.txt +67 -4
- data/llms.txt +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0e650e95ecce31d4acf75d1123d504bc11cacc9725e838643b6fbae235b04c0
|
|
4
|
+
data.tar.gz: 950f370a803af1b1f6aacf87bcaadbf5d00f2d44b1cb54061bee0d74b70e5eae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbf4f32d568839238ce077d0243fe59ad0ba54f8e332b0870c6f23b53c63425f9a41b7842491d862e6bced6ad8bb14db71b1af5b83e704d1833e53b98a7a5ced
|
|
7
|
+
data.tar.gz: 32e1d56e608bc844a452ca9a3a20ffff984c5a64ab1a46dc896498c3543c7deb9c581c5c1fc821ea40cff124b0d5e3ead035fc70e8a6857cce97754ef9131b90
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-09-27
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `RateLimitError`, `Throttle::DailyLimitExhausted` and `Throttle::Timeout` now carry the **`tenant_id`** they relate to, and `Throttle::Timeout` also carries **`retry_after`** (it previously carried nothing at all). A caller recording a durable back-off signal — "this tenant is paused until T, because X" — now gets every fact from the exception instead of inferring the tenant from surrounding context, which breaks as soon as one client serves more than one tenant. `docs/retries-and-rate-limits.md` gains a section on the pattern.
|
|
8
|
+
- Documented that `client.rate_limit` is **process-local**. The reported figures live in memory on the `Client` that made the request, so a worker's reading dies with the job. That is fine for "should this loop stop early?", which is what it is for, and is not a way to tell a web request that a tenant is backed off.
|
|
9
|
+
|
|
3
10
|
## [0.6.0] - 2026-09-27
|
|
4
11
|
|
|
5
12
|
### Fixed
|
|
@@ -68,6 +68,59 @@ When neither source has a reading, `day_remaining` is nil and
|
|
|
68
68
|
reason to halt a sync, and halting would break the common case where the
|
|
69
69
|
first call is what populates the figures.
|
|
70
70
|
|
|
71
|
+
**`client.rate_limit` is process-local.** The reported figures live in
|
|
72
|
+
memory on the `Client` that made the request, so a Sidekiq worker's reading
|
|
73
|
+
is gone when the job ends and no other process can see it. That's fine for
|
|
74
|
+
"should this loop stop early?", which is what it's for. It is not a way to
|
|
75
|
+
tell a web request that a tenant is currently backed off — for that, see
|
|
76
|
+
below.
|
|
77
|
+
|
|
78
|
+
## Recording a durable back-off signal
|
|
79
|
+
|
|
80
|
+
If something outside your sync needs to know a tenant is backed off — a
|
|
81
|
+
dashboard badge, an API response carrying its own `Retry-After`, a guard
|
|
82
|
+
that refuses to start another sync — you need a record that outlives the
|
|
83
|
+
process that observed it.
|
|
84
|
+
|
|
85
|
+
Kiwi deliberately doesn't keep that record for you, but it gives you
|
|
86
|
+
everything needed to keep one. Both back-off exceptions name the tenant,
|
|
87
|
+
say how long, and say which limit was hit:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
begin
|
|
91
|
+
client.each_invoice_page(tenant_id) { |page| import(page) }
|
|
92
|
+
rescue XeroKiwi::RateLimitError => e
|
|
93
|
+
# Xero said so. e.problem is "minute" / "day" / "appminute".
|
|
94
|
+
BackoffRecord.set(e.tenant_id, seconds: e.retry_after || 60, source: :reported)
|
|
95
|
+
raise
|
|
96
|
+
rescue XeroKiwi::Throttle::DailyLimitExhausted => e
|
|
97
|
+
# Our own configured day bucket ran out, before a request went out.
|
|
98
|
+
BackoffRecord.set(e.tenant_id, seconds: e.retry_after, source: :configured)
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Your readers then hit that record directly and never need a `Client`.
|
|
104
|
+
|
|
105
|
+
Three things worth knowing when you write this:
|
|
106
|
+
|
|
107
|
+
- **Only surfaced errors are worth recording.** The retry middleware absorbs
|
|
108
|
+
transient 429s internally and succeeds, so those never reach your rescue —
|
|
109
|
+
which is what you want. Recording every observed 429 would flash "paused"
|
|
110
|
+
at users for something already handled.
|
|
111
|
+
- **`retry_after` can be nil.** Xero usually sends `Retry-After` but isn't
|
|
112
|
+
guaranteed to, so pick your own fallback. Kiwi doesn't invent one, because
|
|
113
|
+
the number you choose is the number your users see as the pause duration —
|
|
114
|
+
that's your decision, not the library's. Watch for a zero-second TTL if
|
|
115
|
+
you store this in Redis; `SETEX` rejects it.
|
|
116
|
+
- **`tenant_id` is on the exception for a reason.** Don't infer it from
|
|
117
|
+
surrounding context — that breaks the moment one client serves more than
|
|
118
|
+
one tenant.
|
|
119
|
+
|
|
120
|
+
`XeroKiwi::Throttle::Timeout` carries `tenant_id` and `retry_after` too, if
|
|
121
|
+
you want to treat a per-minute wait that exceeded `max_wait` as a back-off
|
|
122
|
+
rather than as an error.
|
|
123
|
+
|
|
71
124
|
## What Xero Kiwi does automatically
|
|
72
125
|
|
|
73
126
|
Xero Kiwi sets up a `faraday-retry` middleware that handles transient failures
|
data/docs/throttling.md
CHANGED
|
@@ -132,8 +132,9 @@ is fine. If the wait would exceed `max_wait`, it raises
|
|
|
132
132
|
probably too many concurrent workers for the configured `per_minute`.
|
|
133
133
|
|
|
134
134
|
**Per-day:** the limiter raises `XeroKiwi::Throttle::DailyLimitExhausted`
|
|
135
|
-
immediately, with
|
|
136
|
-
never the right move in a Sidekiq worker, so the caller
|
|
135
|
+
immediately, with `retry_after` in seconds and the `tenant_id` it relates to.
|
|
136
|
+
Sleeping for hours is never the right move in a Sidekiq worker, so the caller
|
|
137
|
+
has to decide:
|
|
137
138
|
|
|
138
139
|
```ruby
|
|
139
140
|
begin
|
|
@@ -145,8 +146,17 @@ rescue XeroKiwi::Throttle::DailyLimitExhausted => e
|
|
|
145
146
|
end
|
|
146
147
|
```
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
Both throttle exceptions carry `tenant_id` and `retry_after`, which mirrors
|
|
150
|
+
the `XeroKiwi::RateLimitError` shape the retry layer raises after exhausting
|
|
151
|
+
retries on a 429 — so one rescue can cover both, and code handling them looks
|
|
152
|
+
the same.
|
|
153
|
+
|
|
154
|
+
If something outside the sync needs to know a tenant is backed off, see
|
|
155
|
+
[recording a durable back-off
|
|
156
|
+
signal](retries-and-rate-limits.md#recording-a-durable-back-off-signal).
|
|
157
|
+
`#remaining` is not that: it only knows about calls made through this
|
|
158
|
+
limiter, so a tenant another application has rate limited looks healthy to
|
|
159
|
+
your own bucket.
|
|
150
160
|
|
|
151
161
|
## Redis key layout
|
|
152
162
|
|
data/lib/xero_kiwi/client.rb
CHANGED
|
@@ -978,7 +978,8 @@ module XeroKiwi
|
|
|
978
978
|
env.status,
|
|
979
979
|
env.body,
|
|
980
980
|
retry_after: env.response_headers["retry-after"]&.to_f,
|
|
981
|
-
problem: env.response_headers["x-rate-limit-problem"]
|
|
981
|
+
problem: env.response_headers["x-rate-limit-problem"],
|
|
982
|
+
tenant_id: env.request_headers[Throttle::Middleware::TENANT_HEADER]
|
|
982
983
|
)
|
|
983
984
|
end
|
|
984
985
|
end
|
data/lib/xero_kiwi/errors.rb
CHANGED
|
@@ -22,12 +22,18 @@ module XeroKiwi
|
|
|
22
22
|
# should treat this as "the user must re-authorise" and surface accordingly.
|
|
23
23
|
class TokenRefreshError < AuthenticationError; end
|
|
24
24
|
|
|
25
|
+
# Raised when Xero returns 429 and the retry middleware has exhausted its
|
|
26
|
+
# attempts. Carries everything needed to record a durable back-off signal —
|
|
27
|
+
# which tenant, for how long, and which of Xero's three limits was hit —
|
|
28
|
+
# without the caller inferring any of it from context. See
|
|
29
|
+
# docs/retries-and-rate-limits.md for the persistence pattern.
|
|
25
30
|
class RateLimitError < APIError
|
|
26
|
-
attr_reader :retry_after, :problem
|
|
31
|
+
attr_reader :retry_after, :problem, :tenant_id
|
|
27
32
|
|
|
28
|
-
def initialize(status, body, retry_after: nil, problem: nil)
|
|
33
|
+
def initialize(status, body, retry_after: nil, problem: nil, tenant_id: nil)
|
|
29
34
|
@retry_after = retry_after
|
|
30
35
|
@problem = problem
|
|
36
|
+
@tenant_id = tenant_id
|
|
31
37
|
super(status, body, "Xero rate limit hit (#{problem || "unknown"}); retry after #{retry_after}s")
|
|
32
38
|
end
|
|
33
39
|
end
|
|
@@ -133,7 +133,7 @@ module XeroKiwi
|
|
|
133
133
|
failed, wait_ms = evaluate(key)
|
|
134
134
|
return if failed.zero?
|
|
135
135
|
|
|
136
|
-
waited_ms = handle_failure(failed, wait_ms, waited_ms)
|
|
136
|
+
waited_ms = handle_failure(failed, wait_ms, waited_ms, key)
|
|
137
137
|
end
|
|
138
138
|
rescue Redis::BaseError => e
|
|
139
139
|
log_redis_failure(e)
|
|
@@ -158,17 +158,24 @@ module XeroKiwi
|
|
|
158
158
|
|
|
159
159
|
private
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
# `key` is threaded down purely so the exceptions can name the tenant
|
|
162
|
+
# they relate to. A caller recording a durable back-off signal needs it,
|
|
163
|
+
# and inferring it from surrounding context breaks the moment one client
|
|
164
|
+
# serves more than one tenant.
|
|
165
|
+
def handle_failure(failed, wait_ms, waited_ms, key)
|
|
162
166
|
case failed
|
|
163
|
-
when 1 then wait_for_minute_bucket(wait_ms, waited_ms)
|
|
164
|
-
when 2 then raise Throttle::DailyLimitExhausted.new(retry_after: wait_ms / 1000.0)
|
|
167
|
+
when 1 then wait_for_minute_bucket(wait_ms, waited_ms, key)
|
|
168
|
+
when 2 then raise Throttle::DailyLimitExhausted.new(retry_after: wait_ms / 1000.0, tenant_id: key)
|
|
165
169
|
end
|
|
166
170
|
end
|
|
167
171
|
|
|
168
|
-
def wait_for_minute_bucket(wait_ms, waited_ms)
|
|
172
|
+
def wait_for_minute_bucket(wait_ms, waited_ms, key)
|
|
169
173
|
if (waited_ms + wait_ms) / 1000.0 > @max_wait
|
|
170
|
-
raise Throttle::Timeout
|
|
171
|
-
|
|
174
|
+
raise Throttle::Timeout.new(
|
|
175
|
+
"waited #{(waited_ms / 1000.0).round(2)}s for rate-limit token, exceeds max_wait=#{@max_wait}s",
|
|
176
|
+
tenant_id: key,
|
|
177
|
+
retry_after: wait_ms / 1000.0
|
|
178
|
+
)
|
|
172
179
|
end
|
|
173
180
|
|
|
174
181
|
@sleeper.call((wait_ms + POLL_MS) / 1000.0)
|
data/lib/xero_kiwi/throttle.rb
CHANGED
|
@@ -13,17 +13,31 @@ module XeroKiwi
|
|
|
13
13
|
# waited longer than the limiter's configured max_wait. The right fix is
|
|
14
14
|
# usually to slow the caller down or raise headroom; swallowing this
|
|
15
15
|
# quietly tends to hide the problem.
|
|
16
|
-
|
|
16
|
+
#
|
|
17
|
+
# Carries `tenant_id` and `retry_after` so a caller recording a durable
|
|
18
|
+
# back-off signal has both facts without inferring either from context.
|
|
19
|
+
class Timeout < Error
|
|
20
|
+
attr_reader :tenant_id, :retry_after
|
|
21
|
+
|
|
22
|
+
# `message` stays positional and first so `raise Throttle::Timeout, "…"`
|
|
23
|
+
# keeps working for anyone who was doing that.
|
|
24
|
+
def initialize(message = nil, tenant_id: nil, retry_after: nil)
|
|
25
|
+
@tenant_id = tenant_id
|
|
26
|
+
@retry_after = retry_after
|
|
27
|
+
super(message || "timed out waiting for a rate-limit token")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
17
30
|
|
|
18
31
|
# Raised immediately (no sleep) when the per-day bucket is exhausted. The
|
|
19
32
|
# wait until reset is typically measured in hours, so blocking the caller
|
|
20
33
|
# is the wrong move — re-enqueue the job at `retry_after` instead. Shape
|
|
21
34
|
# mirrors RateLimitError so existing Xero rate-limit handling applies.
|
|
22
35
|
class DailyLimitExhausted < Error
|
|
23
|
-
attr_reader :retry_after
|
|
36
|
+
attr_reader :retry_after, :tenant_id
|
|
24
37
|
|
|
25
|
-
def initialize(retry_after:)
|
|
38
|
+
def initialize(retry_after:, tenant_id: nil)
|
|
26
39
|
@retry_after = retry_after
|
|
40
|
+
@tenant_id = tenant_id
|
|
27
41
|
super("Xero daily rate limit exhausted; retry in #{retry_after.round}s")
|
|
28
42
|
end
|
|
29
43
|
end
|
data/lib/xero_kiwi/version.rb
CHANGED
data/llms-full.txt
CHANGED
|
@@ -3847,6 +3847,59 @@ When neither source has a reading, `day_remaining` is nil and
|
|
|
3847
3847
|
reason to halt a sync, and halting would break the common case where the
|
|
3848
3848
|
first call is what populates the figures.
|
|
3849
3849
|
|
|
3850
|
+
**`client.rate_limit` is process-local.** The reported figures live in
|
|
3851
|
+
memory on the `Client` that made the request, so a Sidekiq worker's reading
|
|
3852
|
+
is gone when the job ends and no other process can see it. That's fine for
|
|
3853
|
+
"should this loop stop early?", which is what it's for. It is not a way to
|
|
3854
|
+
tell a web request that a tenant is currently backed off — for that, see
|
|
3855
|
+
below.
|
|
3856
|
+
|
|
3857
|
+
## Recording a durable back-off signal
|
|
3858
|
+
|
|
3859
|
+
If something outside your sync needs to know a tenant is backed off — a
|
|
3860
|
+
dashboard badge, an API response carrying its own `Retry-After`, a guard
|
|
3861
|
+
that refuses to start another sync — you need a record that outlives the
|
|
3862
|
+
process that observed it.
|
|
3863
|
+
|
|
3864
|
+
Kiwi deliberately doesn't keep that record for you, but it gives you
|
|
3865
|
+
everything needed to keep one. Both back-off exceptions name the tenant,
|
|
3866
|
+
say how long, and say which limit was hit:
|
|
3867
|
+
|
|
3868
|
+
```ruby
|
|
3869
|
+
begin
|
|
3870
|
+
client.each_invoice_page(tenant_id) { |page| import(page) }
|
|
3871
|
+
rescue XeroKiwi::RateLimitError => e
|
|
3872
|
+
# Xero said so. e.problem is "minute" / "day" / "appminute".
|
|
3873
|
+
BackoffRecord.set(e.tenant_id, seconds: e.retry_after || 60, source: :reported)
|
|
3874
|
+
raise
|
|
3875
|
+
rescue XeroKiwi::Throttle::DailyLimitExhausted => e
|
|
3876
|
+
# Our own configured day bucket ran out, before a request went out.
|
|
3877
|
+
BackoffRecord.set(e.tenant_id, seconds: e.retry_after, source: :configured)
|
|
3878
|
+
raise
|
|
3879
|
+
end
|
|
3880
|
+
```
|
|
3881
|
+
|
|
3882
|
+
Your readers then hit that record directly and never need a `Client`.
|
|
3883
|
+
|
|
3884
|
+
Three things worth knowing when you write this:
|
|
3885
|
+
|
|
3886
|
+
- **Only surfaced errors are worth recording.** The retry middleware absorbs
|
|
3887
|
+
transient 429s internally and succeeds, so those never reach your rescue —
|
|
3888
|
+
which is what you want. Recording every observed 429 would flash "paused"
|
|
3889
|
+
at users for something already handled.
|
|
3890
|
+
- **`retry_after` can be nil.** Xero usually sends `Retry-After` but isn't
|
|
3891
|
+
guaranteed to, so pick your own fallback. Kiwi doesn't invent one, because
|
|
3892
|
+
the number you choose is the number your users see as the pause duration —
|
|
3893
|
+
that's your decision, not the library's. Watch for a zero-second TTL if
|
|
3894
|
+
you store this in Redis; `SETEX` rejects it.
|
|
3895
|
+
- **`tenant_id` is on the exception for a reason.** Don't infer it from
|
|
3896
|
+
surrounding context — that breaks the moment one client serves more than
|
|
3897
|
+
one tenant.
|
|
3898
|
+
|
|
3899
|
+
`XeroKiwi::Throttle::Timeout` carries `tenant_id` and `retry_after` too, if
|
|
3900
|
+
you want to treat a per-minute wait that exceeded `max_wait` as a back-off
|
|
3901
|
+
rather than as an error.
|
|
3902
|
+
|
|
3850
3903
|
## What Xero Kiwi does automatically
|
|
3851
3904
|
|
|
3852
3905
|
Xero Kiwi sets up a `faraday-retry` middleware that handles transient failures
|
|
@@ -4191,8 +4244,9 @@ is fine. If the wait would exceed `max_wait`, it raises
|
|
|
4191
4244
|
probably too many concurrent workers for the configured `per_minute`.
|
|
4192
4245
|
|
|
4193
4246
|
**Per-day:** the limiter raises `XeroKiwi::Throttle::DailyLimitExhausted`
|
|
4194
|
-
immediately, with
|
|
4195
|
-
never the right move in a Sidekiq worker, so the caller
|
|
4247
|
+
immediately, with `retry_after` in seconds and the `tenant_id` it relates to.
|
|
4248
|
+
Sleeping for hours is never the right move in a Sidekiq worker, so the caller
|
|
4249
|
+
has to decide:
|
|
4196
4250
|
|
|
4197
4251
|
```ruby
|
|
4198
4252
|
begin
|
|
@@ -4204,8 +4258,17 @@ rescue XeroKiwi::Throttle::DailyLimitExhausted => e
|
|
|
4204
4258
|
end
|
|
4205
4259
|
```
|
|
4206
4260
|
|
|
4207
|
-
|
|
4208
|
-
|
|
4261
|
+
Both throttle exceptions carry `tenant_id` and `retry_after`, which mirrors
|
|
4262
|
+
the `XeroKiwi::RateLimitError` shape the retry layer raises after exhausting
|
|
4263
|
+
retries on a 429 — so one rescue can cover both, and code handling them looks
|
|
4264
|
+
the same.
|
|
4265
|
+
|
|
4266
|
+
If something outside the sync needs to know a tenant is backed off, see
|
|
4267
|
+
[recording a durable back-off
|
|
4268
|
+
signal](retries-and-rate-limits.md#recording-a-durable-back-off-signal).
|
|
4269
|
+
`#remaining` is not that: it only knows about calls made through this
|
|
4270
|
+
limiter, so a tenant another application has rate limited looks healthy to
|
|
4271
|
+
your own bucket.
|
|
4209
4272
|
|
|
4210
4273
|
## Redis key layout
|
|
4211
4274
|
|
data/llms.txt
CHANGED
|
@@ -65,6 +65,6 @@ XeroKiwi requires Ruby 3.4.1 or newer. Install with `gem "xero-kiwi"`.
|
|
|
65
65
|
## Optional
|
|
66
66
|
|
|
67
67
|
- [README](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/README.md): high-level overview, installation, the documentation table of contents, and the development and release process
|
|
68
|
-
- [Retries and rate limits](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/retries-and-rate-limits.md): how XeroKiwi handles 429s and transient 5xxs, `client.rate_limit(tenant_id)` for remaining quota, tuning the retry policy, the Faraday middleware ordering, why 500 is deliberately not retried
|
|
68
|
+
- [Retries and rate limits](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/retries-and-rate-limits.md): how XeroKiwi handles 429s and transient 5xxs, `client.rate_limit(tenant_id)` for remaining quota (process-local), recording a durable per-tenant back-off signal from the `tenant_id` and `retry_after` carried by RateLimitError and the throttle errors, tuning the retry policy, the Faraday middleware ordering, why 500 is deliberately not retried
|
|
69
69
|
- [Throttling](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/throttling.md): the Redis-backed per-tenant token bucket for coordinating rate limits across processes, `default_throttle`, `#remaining`, fail-open behaviour, writing a custom limiter
|
|
70
70
|
- [CHANGELOG](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/CHANGELOG.md): version history
|