xero-kiwi 0.1.1 → 0.2.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: 8f6a32ee3719881fa7bd110e3bd1abb532e76dfdcddeb93e580199deacf1fe35
4
- data.tar.gz: 3b7ea0828116a3f20ce4557d17f85540859d977db14e1db40cd7113321ecaaa8
3
+ metadata.gz: 775f2c27c6c923aa8ab8c2cbb525d83ab7add7c174315e555428ccd45e520836
4
+ data.tar.gz: 5d5572e18a023da88435a091636094eae2800442348eaf6e9c19c25b1127e1fd
5
5
  SHA512:
6
- metadata.gz: 7182cd4adc572b4fe80ac9a43a3ed5f7075404d5eb3c25ae26e843f3a9b600d6891e5feaafcac202b0cd57258b20b63e72d318766080cf3b42a65c74b055c9d8
7
- data.tar.gz: fb570096febd7f79e7d5643c87a81b7d5839156d576a430a267b1ca44b35fa0ab5c37723e63b8470b679e30bb88a5264c5134ca91b4caea072b7cb05c4372cd5
6
+ metadata.gz: f2f8890ad95b9c4c037a4a7d7470b3b32581730960552f6a21c436f3a9209bbf5af3a9ee78467c230c805d5d86de129f4510021098cc4dc2b2c5100e8bc2862c
7
+ data.tar.gz: 7fd1302cfac5d9e5b2e5a3b0c15a720d9ad08b02d550a65c70e4fb900fa7461ad7be9d3d37c4325aa2d6b2072ac35a6ef5a21b003b63e2cf2561ddf3f4df1e8f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-04-15
4
+
5
+ ### Added
6
+
7
+ - Optional proactive rate-limit throttling via a Redis-backed token bucket, keyed per tenant. Pass `throttle:` to `XeroKiwi::Client.new` to coordinate rate limits across processes (e.g. multiple Sidekiq workers hitting the same Xero tenant). Supports per-minute and per-day limits; per-minute waits are bounded by `max_wait`, per-day exhaustion raises `XeroKiwi::Throttle::DailyLimitExhausted`. Composes with the existing reactive retry layer — neither replaces the other. See `docs/throttling.md`.
8
+
9
+ ### Changed
10
+
11
+ - `redis` is now a runtime dependency (used only if you opt into throttling).
12
+
3
13
  ## [0.1.1] - 2026-04-15
4
14
 
5
15
  - Add `lib/xero-kiwi.rb` shim so `gem "xero-kiwi"` in a Gemfile auto-requires the gem without needing `require: "xero_kiwi"`.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Xero Kiwi
2
2
 
3
- A Ruby wrapper for the [Xero](https://www.xero.com) Accounting API. XeroKiwi handles
3
+ A Ruby wrapper for the [Xero](https://www.xero.com) Accounting API. Xero Kiwi handles
4
4
  the unglamorous parts of integrating with Xero — OAuth2, token refresh, rate
5
5
  limiting, retries — so the rest of your code can focus on the actual business
6
6
  problem.
@@ -33,7 +33,7 @@ gem "xero-kiwi"
33
33
 
34
34
  Then run `bundle install`.
35
35
 
36
- XeroKiwi requires Ruby 3.4.1 or newer.
36
+ Xero Kiwi requires Ruby 3.4.1 or newer.
37
37
 
38
38
  ## Quick start
39
39
 
@@ -70,14 +70,36 @@ below.
70
70
  | [Tokens](docs/tokens.md) | The `XeroKiwi::Token` value object, automatic refresh, revocation, persistence callbacks |
71
71
  | [OAuth](docs/oauth.md) | Authorization URL building, code exchange, PKCE, ID token verification, full Rails-style example |
72
72
  | [Errors](docs/errors.md) | The error hierarchy, what to catch and when |
73
- | [Retries and rate limits](docs/retries-and-rate-limits.md) | How XeroKiwi handles 429s and transient failures, customising the retry policy |
73
+ | [Retries and rate limits](docs/retries-and-rate-limits.md) | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
74
+ | [Throttling](docs/throttling.md) | Redis-backed token bucket for proactive rate-limit coordination across multiple workers |
74
75
 
75
76
  ## Status
76
77
 
77
- XeroKiwi is in early development. The API surface for the features documented above
78
+ Xero Kiwi is in early development. The API surface for the features documented above
78
79
  is stable, but expect new resource methods to be added over time. Breaking
79
80
  changes will be called out in the [changelog](CHANGELOG.md).
80
81
 
82
+ ## Development
83
+
84
+ The gem runs natively via Bundler — nothing is containerised for everyday
85
+ development. A `docker-compose.yml` exists for *external services* the specs
86
+ need, currently just Redis (used by the throttle limiter's Lua-backed specs).
87
+
88
+ ```sh
89
+ bundle install
90
+ docker compose up -d redis # optional; only needed for :redis-tagged specs
91
+ bundle exec rspec
92
+ ```
93
+
94
+ Without Redis running, specs tagged `:redis` are filtered out automatically
95
+ (see [spec/spec_helper.rb](spec/spec_helper.rb)) so the suite still passes.
96
+ Override the test Redis URL with `TEST_REDIS_URL=...` if you don't want the
97
+ default `redis://127.0.0.1:6379/15`.
98
+
99
+ ```sh
100
+ docker compose down # when you're done
101
+ ```
102
+
81
103
  ## Contributing
82
104
 
83
105
  Bug reports and pull requests are welcome on GitHub at
data/Rakefile CHANGED
@@ -44,8 +44,8 @@ LLMS_FULL_PATH = "llms-full.txt"
44
44
  # expected output.
45
45
  def build_llms_full
46
46
  out = +""
47
- out << "# XeroKiwi — full documentation\n\n"
48
- out << "This file is the complete documentation for the XeroKiwi gem (a Ruby wrapper for the Xero Accounting API), assembled into a single document for LLM consumption. It contains the README and every doc in the docs/ folder, in reading order.\n\n"
47
+ out << "# Xero Kiwi — full documentation\n\n"
48
+ out << "This file is the complete documentation for the Xero Kiwi gem (a Ruby wrapper for the Xero Accounting API), assembled into a single document for LLM consumption. It contains the README and every doc in the docs/ folder, in reading order.\n\n"
49
49
  out << "For the curated index version, see llms.txt in the same directory.\n\n"
50
50
  out << "Source: https://github.com/douglasgreyling/xero-kiwi\n\n"
51
51
  LLMS_SOURCE_FILES.each { |path| append_file_block(out, path) }
@@ -0,0 +1,17 @@
1
+ services:
2
+ redis:
3
+ image: redis:7-alpine
4
+ container_name: xero-kiwi-redis
5
+ ports:
6
+ - "6379:6379"
7
+ volumes:
8
+ - redis-data:/data
9
+ healthcheck:
10
+ test: ["CMD", "redis-cli", "ping"]
11
+ interval: 5s
12
+ timeout: 2s
13
+ retries: 5
14
+ restart: unless-stopped
15
+
16
+ volumes:
17
+ redis-data:
@@ -58,7 +58,7 @@ sets.
58
58
  ## Date parsing
59
59
 
60
60
  The `created_date_utc` field uses Xero's .NET JSON timestamp format
61
- (`/Date(946684800000+0000)/`). XeroKiwi parses both .NET JSON and ISO 8601
61
+ (`/Date(946684800000+0000)/`). Xero Kiwi parses both .NET JSON and ISO 8601
62
62
  formats transparently — the attribute is always a UTC `Time` object.
63
63
 
64
64
  ## Error behaviour
@@ -82,7 +82,7 @@ API. Connections use ISO 8601 strings (e.g. `"2019-07-09T23:40:30.1833130"`),
82
82
  but the Accounting API (including Organisation) uses the legacy .NET JSON
83
83
  format: `/Date(1574275974000)/`.
84
84
 
85
- XeroKiwi handles both transparently — all `Time` attributes are parsed to UTC
85
+ Xero Kiwi handles both transparently — all `Time` attributes are parsed to UTC
86
86
  `Time` objects regardless of which format Xero sends. You don't need to think
87
87
  about this unless you're looking at raw cassette data or debugging timestamp
88
88
  issues.
@@ -74,7 +74,7 @@ Two users are `==` if they share the same `user_id`. `#hash` is consistent with
74
74
  ## Date parsing
75
75
 
76
76
  The `updated_date_utc` field uses Xero's .NET JSON timestamp format
77
- (`/Date(1516230549137+0000)/`). XeroKiwi parses both .NET JSON and ISO 8601
77
+ (`/Date(1516230549137+0000)/`). Xero Kiwi parses both .NET JSON and ISO 8601
78
78
  formats transparently — the attribute is always a UTC `Time` object.
79
79
 
80
80
  ## Error behaviour
data/docs/client.md CHANGED
@@ -3,7 +3,7 @@
3
3
  `XeroKiwi::Client` is the entry point for talking to Xero's accounting API. You
4
4
  construct one with credentials and call resource methods on it. The client
5
5
  holds the OAuth token state, knows how to refresh it, and translates HTTP
6
- errors into XeroKiwi exceptions.
6
+ errors into Xero Kiwi exceptions.
7
7
 
8
8
  ## Constructing a client
9
9
 
@@ -97,7 +97,7 @@ skipped: a 401 raises immediately and you handle it in your own code.
97
97
 
98
98
  ## Custom adapters
99
99
 
100
- XeroKiwi uses Faraday under the hood, so you can swap the HTTP adapter for
100
+ Xero Kiwi uses Faraday under the hood, so you can swap the HTTP adapter for
101
101
  testing or for connection pooling:
102
102
 
103
103
  ```ruby
@@ -123,7 +123,7 @@ test adapter swallows refresh requests too.
123
123
 
124
124
  ## Customising the retry policy
125
125
 
126
- `retry_options:` is merged into XeroKiwi's defaults, so you only need to specify
126
+ `retry_options:` is merged into Xero Kiwi's defaults, so you only need to specify
127
127
  overrides:
128
128
 
129
129
  ```ruby
data/docs/connections.md CHANGED
@@ -74,7 +74,7 @@ old_connections - new_connections # diff by id
74
74
 
75
75
  Xero serialises dates in C# DateTime format and frequently omits the timezone
76
76
  marker on values that are documented as UTC (e.g. `"2019-07-09T23:40:30.1833130"`).
77
- XeroKiwi force-appends a `Z` before parsing so you always get a UTC `Time` back —
77
+ Xero Kiwi force-appends a `Z` before parsing so you always get a UTC `Time` back —
78
78
  without this, `Time.parse` would silently fall back to local time and you'd
79
79
  get the wrong instant.
80
80
 
data/docs/errors.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Errors
2
2
 
3
- Every failure path in XeroKiwi raises a typed exception. This page walks through
3
+ Every failure path in Xero Kiwi raises a typed exception. This page walks through
4
4
  the hierarchy, explains what each class means, and tells you what to catch
5
5
  in common situations.
6
6
 
@@ -8,7 +8,7 @@ in common situations.
8
8
 
9
9
  ```
10
10
  StandardError
11
- └─ XeroKiwi::Error (root — catch this for "anything XeroKiwi raised")
11
+ └─ XeroKiwi::Error (root — catch this for "anything Xero Kiwi raised")
12
12
  ├─ XeroKiwi::APIError (root for HTTP responses; carries status + body)
13
13
  │ ├─ XeroKiwi::AuthenticationError (401)
14
14
  │ │ ├─ XeroKiwi::TokenRefreshError (refresh round-trip failed)
@@ -22,7 +22,7 @@ StandardError
22
22
 
23
23
  A few things worth noticing about the shape:
24
24
 
25
- - **Everything XeroKiwi raises** descends from `XeroKiwi::Error`. If you only want
25
+ - **Everything Xero Kiwi raises** descends from `XeroKiwi::Error`. If you only want
26
26
  one rescue clause for "the Xero integration broke," catch this.
27
27
  - **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
28
28
  and `body` attributes you can inspect.
@@ -38,7 +38,7 @@ A few things worth noticing about the shape:
38
38
  ### `XeroKiwi::Error`
39
39
 
40
40
  The root class. Inherits from `StandardError`. You almost never raise this
41
- directly — it exists as a catch-all for code that wants to rescue "any XeroKiwi
41
+ directly — it exists as a catch-all for code that wants to rescue "any Xero Kiwi
42
42
  problem" without enumerating every subclass.
43
43
 
44
44
  ### `XeroKiwi::APIError`
@@ -62,7 +62,7 @@ The access token was rejected. The most common causes:
62
62
  - Token has the wrong scopes for this endpoint.
63
63
  - The wrong tenant ID was passed in the `Xero-Tenant-Id` header.
64
64
 
65
- If your client has refresh capability, XeroKiwi will already have tried to
65
+ If your client has refresh capability, Xero Kiwi will already have tried to
66
66
  refresh and retry exactly once before this raises. Seeing `AuthenticationError`
67
67
  on a refresh-capable client means **the second 401 also failed** — refresh
68
68
  won't fix it, and you need to either re-authorise or surface the error.
@@ -115,9 +115,9 @@ Inspect `error.body` to surface it.
115
115
 
116
116
  ### `XeroKiwi::ServerError` (HTTP 5xx)
117
117
 
118
- A 5xx response that wasn't retried. XeroKiwi retries 502/503/504 automatically
118
+ A 5xx response that wasn't retried. Xero Kiwi retries 502/503/504 automatically
119
119
  (see [retries and rate limits](retries-and-rate-limits.md)) so by the time
120
- you see this, the retries are exhausted or the status was 500 (which XeroKiwi
120
+ you see this, the retries are exhausted or the status was 500 (which Xero Kiwi
121
121
  deliberately doesn't retry, since 500s are usually persistent bugs in the
122
122
  request rather than transient infrastructure issues).
123
123
 
@@ -164,7 +164,7 @@ The error message has a brief description of which check failed (e.g.
164
164
 
165
165
  ## What to catch when
166
166
 
167
- ### "Any XeroKiwi failure"
167
+ ### "Any Xero Kiwi failure"
168
168
 
169
169
  ```ruby
170
170
  begin
@@ -256,12 +256,12 @@ Order matters — `TokenRefreshError` is more specific than
256
256
 
257
257
  ## Things the error system deliberately does NOT do
258
258
 
259
- - **No "this error is retryable" predicate.** XeroKiwi already retries the
259
+ - **No "this error is retryable" predicate.** Xero Kiwi already retries the
260
260
  cases that *should* be retried at the HTTP level. By the time an exception
261
261
  reaches your code, the retries are exhausted and the situation is
262
262
  application-level. Adding `error.retryable?` would create a tempting
263
263
  foot-gun where callers retry inside their own code, doubling up on the
264
- retries XeroKiwi is already doing.
264
+ retries Xero Kiwi is already doing.
265
265
  - **No automatic Sentry / Bugsnag integration.** Errors raise normally;
266
266
  configure your own observability layer to catch them at the boundary.
267
267
  - **No `error.code` enum.** The HTTP `status` is the enum. Inspecting it
@@ -16,7 +16,7 @@ Before you can talk to Xero from Ruby, you need:
16
16
 
17
17
  ## Installing the gem
18
18
 
19
- Add XeroKiwi to your `Gemfile`:
19
+ Add Xero Kiwi to your `Gemfile`:
20
20
 
21
21
  ```ruby
22
22
  gem "xero-kiwi"
@@ -30,7 +30,7 @@ bundle install
30
30
 
31
31
  ## The mental model
32
32
 
33
- XeroKiwi is built around a small set of objects, each with one job:
33
+ Xero Kiwi is built around a small set of objects, each with one job:
34
34
 
35
35
  | Object | What it does |
36
36
  |--------|--------------|
data/docs/oauth.md CHANGED
@@ -198,7 +198,7 @@ token = oauth.exchange_code(
198
198
 
199
199
  If you pass a `pkce:` to `authorization_url` but **forget** the
200
200
  `code_verifier:` on `exchange_code`, Xero will reject the exchange with
201
- `invalid_grant` and XeroKiwi will raise `XeroKiwi::OAuth::CodeExchangeError`.
201
+ `invalid_grant` and Xero Kiwi will raise `XeroKiwi::OAuth::CodeExchangeError`.
202
202
 
203
203
  ## Step 2: handling the callback
204
204
 
@@ -489,7 +489,7 @@ end
489
489
 
490
490
  ## Things OAuth deliberately does NOT do
491
491
 
492
- - **No session storage.** XeroKiwi gives you `generate_state` and
492
+ - **No session storage.** Xero Kiwi gives you `generate_state` and
493
493
  `generate_pkce` as helpers but never touches your session/cookies/Redis.
494
494
  Where you stash the values is your problem — and that's a feature,
495
495
  because every framework is different.
@@ -1,6 +1,6 @@
1
1
  # Retries and rate limits
2
2
 
3
- This doc explains how XeroKiwi handles transient failures: rate limiting, server
3
+ This doc explains how Xero Kiwi handles transient failures: rate limiting, server
4
4
  errors, and network blips. Most of the time you don't need to know any of
5
5
  this — the defaults are sensible. Read on if you need to tune the retry
6
6
  policy or you want to understand what's happening when a request mysteriously
@@ -20,9 +20,9 @@ Xero enforces three separate rate limits, all returned with HTTP 429:
20
20
  Plus a `Retry-After` header on every 429 telling you how many seconds to
21
21
  wait before trying again.
22
22
 
23
- ## What XeroKiwi does automatically
23
+ ## What Xero Kiwi does automatically
24
24
 
25
- XeroKiwi sets up a `faraday-retry` middleware that handles transient failures
25
+ Xero Kiwi sets up a `faraday-retry` middleware that handles transient failures
26
26
  without any code from you. The default retry policy:
27
27
 
28
28
  | Setting | Default | Why |
@@ -38,13 +38,13 @@ without any code from you. The default retry policy:
38
38
  ### Retry-After is honoured
39
39
 
40
40
  `faraday-retry` automatically respects the `Retry-After` header on 429
41
- responses. So if Xero says "wait 30 seconds," XeroKiwi waits 30 seconds before
41
+ responses. So if Xero says "wait 30 seconds," Xero Kiwi waits 30 seconds before
42
42
  retrying — not the exponential backoff schedule. This is the whole reason
43
43
  to use a real retry middleware instead of rolling your own.
44
44
 
45
45
  ### Which 5xx are retried
46
46
 
47
- XeroKiwi retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway
47
+ Xero Kiwi retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway
48
48
  Timeout`. These are the canonical "the upstream is having a temporary
49
49
  problem" statuses.
50
50
 
@@ -66,7 +66,7 @@ The retried request count includes the original attempt, so `max: 4` means
66
66
 
67
67
  ## Customising the retry policy
68
68
 
69
- Pass `retry_options:` to `XeroKiwi::Client.new`. The hash is merged into XeroKiwi's
69
+ Pass `retry_options:` to `XeroKiwi::Client.new`. The hash is merged into Xero Kiwi's
70
70
  defaults, so you only specify what you want to change:
71
71
 
72
72
  ```ruby
@@ -109,7 +109,7 @@ retry_options: {
109
109
  }
110
110
  ```
111
111
 
112
- This is what XeroKiwi's own test suite uses to keep specs deterministic and
112
+ This is what Xero Kiwi's own test suite uses to keep specs deterministic and
113
113
  fast.
114
114
 
115
115
  **Adding 500 to the retry list** (against my advice, but sometimes you
@@ -123,7 +123,7 @@ retry_options: {
123
123
 
124
124
  ### One thing you must NOT remove
125
125
 
126
- XeroKiwi's default `exceptions:` list includes `Faraday::RetriableResponse`,
126
+ Xero Kiwi's default `exceptions:` list includes `Faraday::RetriableResponse`,
127
127
  which is the *internal* signal `faraday-retry` uses to flag a status-code
128
128
  retry. **It must stay in the list**, or the retry middleware can't catch
129
129
  its own retry signal and 429s/503s will never be retried — they'll bubble
@@ -169,7 +169,7 @@ needs to know about `RateLimitError` to know to retry it. That's brittle.
169
169
  By putting Retry on the inside, the retry middleware sees raw HTTP envs
170
170
  with status 429 and uses its own `retry_statuses` config to decide what to
171
171
  do. ResponseHandler only sees the *final* env (after retries are done) and
172
- maps it to a XeroKiwi exception.
172
+ maps it to a Xero Kiwi exception.
173
173
 
174
174
  You don't need to think about any of this — it's the gem's job — but if
175
175
  you ever subclass the client or insert your own middleware, this is the
@@ -202,7 +202,7 @@ retry/backoff schedules — they won't coordinate.
202
202
 
203
203
  If you need cross-thread coordination (e.g. "all threads should pause when
204
204
  any one of them hits a 429"), build it at the application level using a
205
- shared semaphore or rate limiter. XeroKiwi doesn't ship one, because the right
205
+ shared semaphore or rate limiter. Xero Kiwi doesn't ship one, because the right
206
206
  shape depends entirely on your traffic patterns.
207
207
 
208
208
  ## Things the retry layer deliberately does NOT do
@@ -212,13 +212,15 @@ shape depends entirely on your traffic patterns.
212
212
  - **No retry on 4xx (other than 429).** 4xx means the client did something
213
213
  wrong; retrying won't fix it. The exception is 429 (rate limit), which is
214
214
  classified as 4xx but is fundamentally a "wait and try again" signal.
215
- - **No global rate limiter.** XeroKiwi reacts to 429s as they happen but
216
- doesn't proactively throttle it'll happily fire 100 concurrent requests
217
- and rely on Xero's 429s to slow things down. If you need proactive
218
- throttling, do it at your job-queue level.
215
+ - **No global rate limiter *by default*.** Xero Kiwi reacts to 429s as they
216
+ happen but doesn't proactively throttle unless you opt in. For multi-worker
217
+ setups where several processes hit the same tenant, wire up the
218
+ Redis-backed token bucket described in
219
+ [throttling.md](throttling.md) — it composes with this retry layer rather
220
+ than replacing it.
219
221
  - **No retry budget across calls.** Each `client.connections` (or any
220
222
  other call) gets a fresh `max` retries. There's no concept of "this client
221
223
  has had too many retries today and should stop trying."
222
224
  - **No automatic Sidekiq integration.** When `RateLimitError` raises, it's
223
- up to your job to re-enqueue using the `retry_after` value. XeroKiwi exposes
225
+ up to your job to re-enqueue using the `retry_after` value. Xero Kiwi exposes
224
226
  it; what you do with it is your call.
@@ -0,0 +1,183 @@
1
+ # Proactive throttling
2
+
3
+ Xero Kiwi's retry middleware (see
4
+ [retries-and-rate-limits.md](retries-and-rate-limits.md)) handles 429s *after*
5
+ they happen — it honours `Retry-After` and backs off. That's fine when calls
6
+ are infrequent, but Xero treats *hitting* the rate limit as a misbehaviour
7
+ signal, and multi-worker setups (e.g. several Sidekiq processes syncing the
8
+ same tenant) regularly trip it.
9
+
10
+ The throttle layer is the other half of the story: block *before* the request
11
+ goes out so you rarely hit 429 in the first place. It's **opt-in** — omit
12
+ the `throttle:` kwarg and behaviour is identical to previous versions.
13
+
14
+ ## When to reach for this
15
+
16
+ Wire up a limiter if:
17
+
18
+ - Multiple processes or workers can call Xero for the same tenant concurrently.
19
+ - You see sporadic 429s under normal load (not just traffic spikes).
20
+ - You want predictable pacing rather than "fire everything, react to 429s."
21
+
22
+ Skip it if you have a single-process, single-worker caller. The retry layer is
23
+ enough.
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ require "redis"
29
+
30
+ throttle = XeroKiwi::Throttle::RedisTokenBucket.new(
31
+ redis: Redis.new(url: ENV["REDIS_URL"]),
32
+ per_minute: 55, # Xero's default is 60. Leave a bit of headroom.
33
+ per_day: 4_900, # optional. Xero's default is 5,000.
34
+ max_wait: 30.0 # cap on how long we'll block for a per-minute token.
35
+ )
36
+
37
+ client = XeroKiwi::Client.new(
38
+ access_token: access_token,
39
+ throttle: throttle
40
+ )
41
+
42
+ client.organisation(tenant_id) # blocks briefly if the bucket is empty
43
+ ```
44
+
45
+ Same `throttle:` instance across all clients that share a Redis — that's how
46
+ coordination happens.
47
+
48
+ ## How it works
49
+
50
+ A token bucket per tenant, stored as a Redis hash. Each call to Xero consumes
51
+ a token; tokens refill at `capacity / window` per millisecond. All of the
52
+ read-modify-write runs inside a Lua script, so two workers racing on the same
53
+ bucket can't both spend the same token.
54
+
55
+ The middleware reads `Xero-Tenant-Id` from the outgoing request and asks the
56
+ limiter for a token before the HTTP call goes out. Untenanted requests
57
+ (`/connections`, OAuth endpoints) bypass the middleware — they have no
58
+ bucket.
59
+
60
+ The middleware sits *below* the retry middleware in the Faraday stack, which
61
+ means every retry attempt also consumes a token. So a burst of 429s doesn't
62
+ starve other tenants' throughput.
63
+
64
+ ## Composing with the retry middleware
65
+
66
+ Both layers stay on. They catch different failures:
67
+
68
+ | Layer | Fires on | Action |
69
+ |-------|----------|--------|
70
+ | Throttle (proactive) | Your own bucket count | Sleep, then retry the acquire |
71
+ | Retry (reactive) | A 429 that still slipped through | Honour `Retry-After` and retry the HTTP call |
72
+
73
+ You can't just disable the retry layer once the throttle is in place:
74
+
75
+ - Your bucket only models *your* calls to one tenant. The per-app 10k/min
76
+ limit is shared with anything else hitting the same Xero credentials.
77
+ - Clock skew between Redis and Xero's own clock means your 60/min window
78
+ doesn't line up perfectly with theirs.
79
+ - If Redis briefly fails, the limiter fails open (see below) — retry is the
80
+ safety net.
81
+
82
+ ## Choosing limits
83
+
84
+ Pick values *below* Xero's defaults:
85
+
86
+ | Xero limit | Headroom suggestion |
87
+ |------------|---------------------|
88
+ | 60 calls/min per tenant | `per_minute: 50` – `55` |
89
+ | 5,000 calls/day per tenant | `per_day: 4,700` – `4,900` |
90
+
91
+ The exact number depends on how much you care about the occasional 429 vs.
92
+ maximising throughput. If your job batches run for hours, lean conservative —
93
+ the daily limit resets on Xero's clock, not yours, and the first few
94
+ minutes after "daily reset" can be ambiguous.
95
+
96
+ ## Per-minute vs per-day failure modes
97
+
98
+ The two buckets fail differently on purpose.
99
+
100
+ **Per-minute:** the limiter sleeps (up to `max_wait`) and retries. Short waits
101
+ are normal and expected — a worker pausing 2 seconds to let the bucket refill
102
+ is fine. If the wait would exceed `max_wait`, it raises
103
+ `XeroKiwi::Throttle::Timeout`. Treat that as "something upstream is wrong" —
104
+ probably too many concurrent workers for the configured `per_minute`.
105
+
106
+ **Per-day:** the limiter raises `XeroKiwi::Throttle::DailyLimitExhausted`
107
+ immediately, with a `retry_after` attribute in seconds. Sleeping for hours is
108
+ never the right move in a Sidekiq worker, so the caller has to decide:
109
+
110
+ ```ruby
111
+ begin
112
+ client.invoices(tenant_id)
113
+ rescue XeroKiwi::Throttle::DailyLimitExhausted => e
114
+ # Re-enqueue the job for tomorrow. `retry_after` is seconds until the
115
+ # bucket has at least one token.
116
+ MyJob.perform_in(e.retry_after, org_id)
117
+ end
118
+ ```
119
+
120
+ This mirrors the `XeroKiwi::RateLimitError` shape that the retry layer raises
121
+ after exhausting retries on a 429, so the handling code is familiar.
122
+
123
+ ## Redis key layout
124
+
125
+ Buckets live under a namespace (`xero_kiwi:throttle` by default):
126
+
127
+ ```
128
+ xero_kiwi:throttle:<tenant_id>:minute
129
+ xero_kiwi:throttle:<tenant_id>:day
130
+ ```
131
+
132
+ Each key is a Redis hash with `tokens` (float) and `last_refill_ms`. Keys
133
+ carry a `PEXPIRE` of `2 × window` so stale tenants clean themselves up.
134
+
135
+ Override the namespace with `namespace:` if you're sharing a Redis with other
136
+ rate-limiter traffic:
137
+
138
+ ```ruby
139
+ XeroKiwi::Throttle::RedisTokenBucket.new(
140
+ redis: Redis.new,
141
+ per_minute: 55,
142
+ namespace: "myapp:xero"
143
+ )
144
+ ```
145
+
146
+ ## What happens if Redis is down
147
+
148
+ The limiter fails open. If Redis raises (connection refused, timeout), the
149
+ limiter logs a warning via `Kernel.warn` and returns immediately so the
150
+ request still goes out. The retry middleware will still catch any 429s that
151
+ result.
152
+
153
+ Pass a `logger:` to route warnings somewhere useful:
154
+
155
+ ```ruby
156
+ XeroKiwi::Throttle::RedisTokenBucket.new(
157
+ redis: Redis.new,
158
+ per_minute: 55,
159
+ logger: Rails.logger
160
+ )
161
+ ```
162
+
163
+ Fail-open is deliberate: a misbehaving Redis shouldn't stop your app talking
164
+ to Xero. The reactive retry layer still protects you from actually hitting
165
+ the limits.
166
+
167
+ ## Writing a custom limiter
168
+
169
+ The limiter contract is one method:
170
+
171
+ ```ruby
172
+ class MyLimiter
173
+ def acquire(tenant_id)
174
+ # Block until a token is available for this tenant, or raise
175
+ # XeroKiwi::Throttle::Timeout / DailyLimitExhausted if you want the
176
+ # same exception shapes.
177
+ end
178
+ end
179
+ ```
180
+
181
+ Pass any object implementing it as `throttle:`. The built-in
182
+ `XeroKiwi::Throttle::NullLimiter` is a no-op default — it's what runs when
183
+ `throttle:` is omitted.
data/docs/tokens.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tokens
2
2
 
3
- This doc covers everything about token *state* in XeroKiwi: the `XeroKiwi::Token`
3
+ This doc covers everything about token *state* in Xero Kiwi: the `XeroKiwi::Token`
4
4
  value object, how the client refreshes tokens automatically, the persistence
5
5
  callback, manual refresh, revocation, and the gotchas around token rotation.
6
6
 
@@ -77,7 +77,7 @@ The `now:` keyword arg lets you inject a fixed time for testing.
77
77
  ### Why `expired?` returns false on nil `expires_at`
78
78
 
79
79
  If you don't know when the token expires (e.g. you loaded a credential from
80
- storage that was created before you tracked expiry), XeroKiwi treats it as
80
+ storage that was created before you tracked expiry), Xero Kiwi treats it as
81
81
  "unknown" and assumes valid. The fallback is reactive — your first 401 will
82
82
  trigger a refresh.
83
83
 
@@ -163,7 +163,7 @@ token and fail.
163
163
  ### Persistence patterns
164
164
 
165
165
  Different applications store credentials differently. The callback works the
166
- same in all of them — XeroKiwi just hands you the new token and trusts you to
166
+ same in all of them — Xero Kiwi just hands you the new token and trusts you to
167
167
  put it somewhere:
168
168
 
169
169
  ```ruby
@@ -200,7 +200,7 @@ integrations:
200
200
  > and gets `invalid_grant`. From Worker B's perspective, the credential
201
201
  > looks dead — but it's not, A just rotated it.
202
202
 
203
- XeroKiwi mitigates this in two ways:
203
+ Xero Kiwi mitigates this in two ways:
204
204
 
205
205
  1. **Single-process safety.** A `Mutex` around refresh, with a double-check
206
206
  inside, prevents multiple threads in the *same process* from racing.
@@ -253,7 +253,7 @@ Use Redis (or Postgres advisory locks, etc.) to take a distributed lock
253
253
  keyed by credential ID before refreshing. Slower but more robust than
254
254
  Option B.
255
255
 
256
- XeroKiwi doesn't ship any of these — they're application-level decisions that
256
+ Xero Kiwi doesn't ship any of these — they're application-level decisions that
257
257
  depend on your infrastructure. But the existence of `client.token.refreshable?`
258
258
  and the explicit `XeroKiwi::TokenRefreshError` give you the building blocks.
259
259
 
@@ -279,7 +279,7 @@ This:
279
279
 
280
280
  After revocation, **treat the client as dead.** Subsequent API calls will
281
281
  401, and reactive refresh will fail because the refresh token is gone too.
282
- XeroKiwi doesn't set an internal flag on the client — there's no
282
+ Xero Kiwi doesn't set an internal flag on the client — there's no
283
283
  `client.revoked?` predicate — because the right thing for a caller to do
284
284
  post-revocation is throw the client away, not keep using it.
285
285
 
@@ -307,7 +307,7 @@ Xero accepts both. **But** revoking the access token only kills that one
307
307
  access token — the refresh token stays alive and can mint a new one
308
308
  immediately. Revoking the refresh token invalidates the entire chain.
309
309
 
310
- XeroKiwi enforces the refresh-token path: `Client#revoke_token!` raises if you
310
+ Xero Kiwi enforces the refresh-token path: `Client#revoke_token!` raises if you
311
311
  don't have a refresh token, rather than silently revoking the access token
312
312
  (which would do almost nothing useful). This avoids a foot-gun where users
313
313
  think they've logged out but the token is still happily working.
@@ -334,6 +334,6 @@ log the full hash.
334
334
  full hash, which works for "is this the same token?" but isn't a security
335
335
  check. Don't use it for authentication.
336
336
  - **No JWT decoding of the access token.** Xero's access tokens are JWTs,
337
- but XeroKiwi doesn't peek at their contents. The `id_token` is the OIDC
337
+ but Xero Kiwi doesn't peek at their contents. The `id_token` is the OIDC
338
338
  identity assertion you should care about; see [OAuth](oauth.md#id-token-verification)
339
339
  for verifying it.
@@ -62,7 +62,8 @@ module XeroKiwi
62
62
  on_token_refresh: nil,
63
63
  adapter: nil,
64
64
  user_agent: DEFAULT_USER_AGENT,
65
- retry_options: {}
65
+ retry_options: {},
66
+ throttle: nil
66
67
  )
67
68
  @token = Token.new(
68
69
  access_token: access_token,
@@ -75,6 +76,7 @@ module XeroKiwi
75
76
  @adapter = adapter
76
77
  @user_agent = user_agent
77
78
  @retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
79
+ @throttle = throttle || Throttle::NullLimiter.new
78
80
  @refresh_mutex = Mutex.new
79
81
  end
80
82
 
@@ -522,8 +524,10 @@ module XeroKiwi
522
524
  # a XeroKiwi exception, *after* retries have been exhausted.
523
525
  # 2. Retry — retries on 429/503 (respecting Retry-After) and on transport
524
526
  # exceptions.
525
- # 3. JSONparses the response body so handlers downstream get a Hash.
526
- # 4. Adapter actually makes the HTTP call.
527
+ # 3. Throttleblocks before each attempt until a per-tenant token is
528
+ # available. Below Retry so every retry also consumes a token.
529
+ # 4. JSON — parses the response body so handlers downstream get a Hash.
530
+ # 5. Adapter — actually makes the HTTP call.
527
531
  #
528
532
  # Putting ResponseHandler outside Retry is the key trick: it means a 429
529
533
  # gets retried by Faraday before we ever raise RateLimitError, and the
@@ -532,6 +536,7 @@ module XeroKiwi
532
536
  Faraday.new(url: BASE_URL) do |f|
533
537
  f.use ResponseHandler
534
538
  f.request :retry, @retry_options
539
+ f.use Throttle::Middleware, @throttle
535
540
  f.response :json, content_type: /\bjson/
536
541
  f.adapter(@adapter || Faraday.default_adapter)
537
542
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module XeroKiwi
6
+ module Throttle
7
+ # Faraday request middleware. On every outbound call it reads the
8
+ # `Xero-Tenant-Id` header and asks the limiter for a token. Requests
9
+ # without a tenant header (e.g. `/connections`, OAuth endpoints) pass
10
+ # straight through — they have no tenant bucket to check.
11
+ #
12
+ # Placement matters: this sits *below* faraday-retry in the stack, so
13
+ # every retry attempt also re-enters the limiter and consumes a token.
14
+ class Middleware < Faraday::Middleware
15
+ TENANT_HEADER = "Xero-Tenant-Id"
16
+
17
+ def initialize(app, limiter)
18
+ super(app)
19
+ @limiter = limiter
20
+ end
21
+
22
+ def on_request(env)
23
+ tenant_id = env.request_headers[TENANT_HEADER]
24
+ return if tenant_id.nil? || tenant_id.empty?
25
+
26
+ @limiter.acquire(tenant_id)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroKiwi
4
+ module Throttle
5
+ # Default limiter when no `throttle:` is passed to Client.new. Does nothing
6
+ # — preserves the pre-throttle behaviour where calls go straight out and
7
+ # the retry middleware reacts to any 429s that come back.
8
+ #
9
+ # Also documents the limiter contract: any object implementing
10
+ # `#acquire(key)` can be passed as `throttle:`.
11
+ class NullLimiter
12
+ def acquire(_key)
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "redis"
5
+
6
+ module XeroKiwi
7
+ module Throttle
8
+ # Redis-backed token bucket, keyed per tenant. One Ruby instance is shared
9
+ # across threads and (via Redis) across processes, so N Sidekiq workers
10
+ # hitting the same Xero tenant cooperatively share a bucket.
11
+ #
12
+ # Two buckets per tenant — minute and (optionally) day — modelled as Redis
13
+ # hashes with `tokens` and `last_refill_ms` fields. All bucket math runs
14
+ # inside a Lua script so the read-modify-write is atomic server-side; doing
15
+ # it in Ruby with separate GET/SET calls would race and leak tokens.
16
+ #
17
+ # bucket = XeroKiwi::Throttle::RedisTokenBucket.new(
18
+ # redis: Redis.new,
19
+ # per_minute: 55, # Xero's default is 60; leave headroom.
20
+ # per_day: 4_900, # optional. Xero's default is 5,000.
21
+ # max_wait: 30.0 # cap on how long acquire may block.
22
+ # )
23
+ class RedisTokenBucket
24
+ DEFAULT_NAMESPACE = "xero_kiwi:throttle"
25
+ MINUTE_MS = 60_000
26
+ DAY_MS = 86_400_000
27
+ # Extra ms we sleep past a refill-time hint to avoid a busy loop.
28
+ POLL_MS = 1_000
29
+
30
+ # Lua script. Input ARGV: now_ms, capacities... (minute, day?), window_ms... (minute, day?).
31
+ # KEYS: bucket hash keys in the same order as capacities.
32
+ #
33
+ # Returns: { failed_bucket_index, wait_ms }
34
+ # {0, 0} = granted everywhere (decrements committed)
35
+ # {i, N} = bucket i (1-indexed) is empty; no decrements committed; wait N ms for it
36
+ #
37
+ # Granting is all-or-nothing across buckets: if any bucket is empty we
38
+ # roll back, so a day-limit failure doesn't burn a minute token.
39
+ LUA_SCRIPT = <<~LUA
40
+ local now_ms = tonumber(ARGV[1])
41
+ local n = #KEYS
42
+ local new_tokens = {}
43
+
44
+ for i = 1, n do
45
+ local capacity = tonumber(ARGV[1 + i])
46
+ local window_ms = tonumber(ARGV[1 + n + i])
47
+ local refill_per_ms = capacity / window_ms
48
+
49
+ local data = redis.call("HMGET", KEYS[i], "tokens", "last_refill_ms")
50
+ local tokens = tonumber(data[1]) or capacity
51
+ local last_refill_ms = tonumber(data[2]) or now_ms
52
+
53
+ local elapsed = now_ms - last_refill_ms
54
+ if elapsed < 0 then elapsed = 0 end
55
+ tokens = math.min(capacity, tokens + elapsed * refill_per_ms)
56
+
57
+ if tokens < 1 then
58
+ local shortfall = 1 - tokens
59
+ local wait_ms = math.ceil(shortfall / refill_per_ms)
60
+ return { i, wait_ms }
61
+ end
62
+
63
+ new_tokens[i] = tokens - 1
64
+ end
65
+
66
+ for i = 1, n do
67
+ local window_ms = tonumber(ARGV[1 + n + i])
68
+ redis.call("HSET", KEYS[i], "tokens", new_tokens[i], "last_refill_ms", now_ms)
69
+ redis.call("PEXPIRE", KEYS[i], window_ms * 2)
70
+ end
71
+
72
+ return { 0, 0 }
73
+ LUA
74
+
75
+ LUA_SHA = Digest::SHA1.hexdigest(LUA_SCRIPT)
76
+
77
+ DEFAULT_CLOCK = -> { (Process.clock_gettime(Process::CLOCK_REALTIME) * 1000).to_i }
78
+ DEFAULT_SLEEPER = ->(seconds) { Kernel.sleep(seconds) }
79
+
80
+ def initialize(redis:, per_minute:, per_day: nil, namespace: DEFAULT_NAMESPACE,
81
+ max_wait: 30.0, logger: nil, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER)
82
+ raise ArgumentError, "per_minute must be > 0" unless per_minute.to_i.positive?
83
+ raise ArgumentError, "per_day must be > 0 when given" if per_day && !per_day.to_i.positive?
84
+
85
+ @redis = redis
86
+ @per_minute = per_minute.to_i
87
+ @per_day = per_day&.to_i
88
+ @namespace = namespace
89
+ @max_wait = max_wait.to_f
90
+ @logger = logger
91
+ @clock = clock
92
+ @sleeper = sleeper
93
+ end
94
+
95
+ # Blocks until a token is available in every configured bucket. Fails
96
+ # open on Redis errors (logs and returns) so a dead Redis can't stop
97
+ # the app — the reactive retry layer still catches any resulting 429s.
98
+ def acquire(key)
99
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
100
+
101
+ waited_ms = 0
102
+
103
+ loop do
104
+ failed, wait_ms = evaluate(key)
105
+ return if failed.zero?
106
+
107
+ waited_ms = handle_failure(failed, wait_ms, waited_ms)
108
+ end
109
+ rescue Redis::BaseError => e
110
+ log_redis_failure(e)
111
+ nil
112
+ end
113
+
114
+ private
115
+
116
+ def handle_failure(failed, wait_ms, waited_ms)
117
+ case failed
118
+ when 1 then wait_for_minute_bucket(wait_ms, waited_ms)
119
+ when 2 then raise Throttle::DailyLimitExhausted.new(retry_after: wait_ms / 1000.0)
120
+ end
121
+ end
122
+
123
+ def wait_for_minute_bucket(wait_ms, waited_ms)
124
+ if (waited_ms + wait_ms) / 1000.0 > @max_wait
125
+ raise Throttle::Timeout,
126
+ "waited #{(waited_ms / 1000.0).round(2)}s for rate-limit token, exceeds max_wait=#{@max_wait}s"
127
+ end
128
+
129
+ @sleeper.call((wait_ms + POLL_MS) / 1000.0)
130
+ waited_ms + wait_ms + POLL_MS
131
+ end
132
+
133
+ def evaluate(key)
134
+ keys, capacities, windows = bucket_args(key)
135
+ argv = [@clock.call, *capacities, *windows]
136
+
137
+ begin
138
+ @redis.evalsha(LUA_SHA, keys: keys, argv: argv)
139
+ rescue Redis::CommandError => e
140
+ raise unless e.message.include?("NOSCRIPT")
141
+
142
+ @redis.eval(LUA_SCRIPT, keys: keys, argv: argv)
143
+ end
144
+ end
145
+
146
+ def bucket_args(key)
147
+ keys = ["#{@namespace}:#{key}:minute"]
148
+ capacities = [@per_minute]
149
+ windows = [MINUTE_MS]
150
+
151
+ if @per_day
152
+ keys << "#{@namespace}:#{key}:day"
153
+ capacities << @per_day
154
+ windows << DAY_MS
155
+ end
156
+
157
+ [keys, capacities, windows]
158
+ end
159
+
160
+ def log_redis_failure(error)
161
+ message = "[xero-kiwi] throttle limiter Redis error (failing open): #{error.class}: #{error.message}"
162
+ if @logger
163
+ @logger.warn(message)
164
+ else
165
+ Kernel.warn(message)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroKiwi
4
+ # Proactive rate-limit coordination for multi-process callers hitting the
5
+ # same Xero tenant. The retry middleware in Client handles reactive 429s;
6
+ # this module blocks *before* a request goes out so 429s become rare.
7
+ #
8
+ # See docs/throttling.md for the full story.
9
+ module Throttle
10
+ class Error < XeroKiwi::Error; end
11
+
12
+ # Raised when the per-minute bucket is empty and the caller has already
13
+ # waited longer than the limiter's configured max_wait. The right fix is
14
+ # usually to slow the caller down or raise headroom; swallowing this
15
+ # quietly tends to hide the problem.
16
+ class Timeout < Error; end
17
+
18
+ # Raised immediately (no sleep) when the per-day bucket is exhausted. The
19
+ # wait until reset is typically measured in hours, so blocking the caller
20
+ # is the wrong move — re-enqueue the job at `retry_after` instead. Shape
21
+ # mirrors RateLimitError so existing Xero rate-limit handling applies.
22
+ class DailyLimitExhausted < Error
23
+ attr_reader :retry_after
24
+
25
+ def initialize(retry_after:)
26
+ @retry_after = retry_after
27
+ super("Xero daily rate limit exhausted; retry in #{retry_after.round}s")
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ require_relative "throttle/null_limiter"
34
+ require_relative "throttle/redis_token_bucket"
35
+ require_relative "throttle/middleware"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/xero_kiwi.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "xero_kiwi/accounting/invoice"
22
22
  require_relative "xero_kiwi/accounting/allocation"
23
23
  require_relative "xero_kiwi/accounting/user"
24
24
  require_relative "xero_kiwi/accounting/branding_theme"
25
+ require_relative "xero_kiwi/throttle"
25
26
  require_relative "xero_kiwi/client"
26
27
  require_relative "xero_kiwi/identity"
27
28
  require_relative "xero_kiwi/token_refresher"
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.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Douglas Greyling
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '2.7'
68
+ - !ruby/object:Gem::Dependency
69
+ name: redis
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
68
82
  description: Xero Kiwi handles the unglamorous parts of integrating with Xero — OAuth2
69
83
  with PKCE, automatic token refresh, rate-limit-aware retries, and typed value objects
70
84
  for accounting resources — so your code can focus on the business problem rather
@@ -80,6 +94,7 @@ files:
80
94
  - LICENSE.txt
81
95
  - README.md
82
96
  - Rakefile
97
+ - docker-compose.yml
83
98
  - docs/accounting/address.md
84
99
  - docs/accounting/branding-theme.md
85
100
  - docs/accounting/contact-group.md
@@ -100,6 +115,7 @@ files:
100
115
  - docs/getting-started.md
101
116
  - docs/oauth.md
102
117
  - docs/retries-and-rate-limits.md
118
+ - docs/throttling.md
103
119
  - docs/tokens.md
104
120
  - lib/xero-kiwi.rb
105
121
  - lib/xero_kiwi.rb
@@ -128,6 +144,10 @@ files:
128
144
  - lib/xero_kiwi/oauth.rb
129
145
  - lib/xero_kiwi/oauth/id_token.rb
130
146
  - lib/xero_kiwi/oauth/pkce.rb
147
+ - lib/xero_kiwi/throttle.rb
148
+ - lib/xero_kiwi/throttle/middleware.rb
149
+ - lib/xero_kiwi/throttle/null_limiter.rb
150
+ - lib/xero_kiwi/throttle/redis_token_bucket.rb
131
151
  - lib/xero_kiwi/token.rb
132
152
  - lib/xero_kiwi/token_refresher.rb
133
153
  - lib/xero_kiwi/version.rb