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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3c9271654c527275c19a5738effb1f5d334844a14898eaf467fc6671fbc308a
4
- data.tar.gz: 0645a88a9ccd58130524e24d9981f3999b74ddf8ee98af9600a73f45cda4d29e
3
+ metadata.gz: e0e650e95ecce31d4acf75d1123d504bc11cacc9725e838643b6fbae235b04c0
4
+ data.tar.gz: 950f370a803af1b1f6aacf87bcaadbf5d00f2d44b1cb54061bee0d74b70e5eae
5
5
  SHA512:
6
- metadata.gz: 74bbf0e97f3207d0aaaa16c85a82fe5c1f8572ef10c3c5d802ed2b051538ad002c9aae5df62da3f9ffd7aa76e64d4446a33cfcb98ac5d9eec3edc2f6a143d1bb
7
- data.tar.gz: 32141ab2ffa3f40865a432711e1e6147c99006325e30f633f035790ab1ffefd085021eda40d5f2e22621eda084fb34174691fe6c65c405497ab7452106c9aa69
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 a `retry_after` attribute in seconds. Sleeping for hours is
136
- never the right move in a Sidekiq worker, so the caller has to decide:
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
- This mirrors the `XeroKiwi::RateLimitError` shape that the retry layer raises
149
- after exhausting retries on a 429, so the handling code is familiar.
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
 
@@ -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
@@ -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
- def handle_failure(failed, wait_ms, waited_ms)
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
- "waited #{(waited_ms / 1000.0).round(2)}s for rate-limit token, exceeds max_wait=#{@max_wait}s"
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)
@@ -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
- class Timeout < Error; end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
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 a `retry_after` attribute in seconds. Sleeping for hours is
4195
- never the right move in a Sidekiq worker, so the caller has to decide:
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
- This mirrors the `XeroKiwi::RateLimitError` shape that the retry layer raises
4208
- after exhausting retries on a 429, so the handling code is familiar.
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xero-kiwi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Douglas Greyling