xero-kiwi 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +26 -4
- data/Rakefile +2 -2
- data/docker-compose.yml +17 -0
- data/docs/accounting/branding-theme.md +1 -1
- data/docs/accounting/organisation.md +1 -1
- data/docs/accounting/user.md +1 -1
- data/docs/client.md +3 -3
- data/docs/connections.md +1 -1
- data/docs/errors.md +10 -10
- data/docs/getting-started.md +2 -2
- data/docs/oauth.md +2 -2
- data/docs/retries-and-rate-limits.md +17 -15
- data/docs/throttling.md +183 -0
- data/docs/tokens.md +8 -8
- data/lib/xero-kiwi.rb +3 -0
- data/lib/xero_kiwi/client.rb +8 -3
- data/lib/xero_kiwi/throttle/middleware.rb +30 -0
- data/lib/xero_kiwi/throttle/null_limiter.rb +17 -0
- data/lib/xero_kiwi/throttle/redis_token_bucket.rb +170 -0
- data/lib/xero_kiwi/throttle.rb +35 -0
- data/lib/xero_kiwi/version.rb +1 -1
- data/lib/xero_kiwi.rb +1 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 775f2c27c6c923aa8ab8c2cbb525d83ab7add7c174315e555428ccd45e520836
|
|
4
|
+
data.tar.gz: 5d5572e18a023da88435a091636094eae2800442348eaf6e9c19c25b1127e1fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2f8890ad95b9c4c037a4a7d7470b3b32581730960552f6a21c436f3a9209bbf5af3a9ee78467c230c805d5d86de129f4510021098cc4dc2b2c5100e8bc2862c
|
|
7
|
+
data.tar.gz: 7fd1302cfac5d9e5b2e5a3b0c15a720d9ad08b02d550a65c70e4fb900fa7461ad7be9d3d37c4325aa2d6b2072ac35a6ef5a21b003b63e2cf2561ddf3f4df1e8f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.1.1] - 2026-04-15
|
|
14
|
+
|
|
15
|
+
- Add `lib/xero-kiwi.rb` shim so `gem "xero-kiwi"` in a Gemfile auto-requires the gem without needing `require: "xero_kiwi"`.
|
|
16
|
+
|
|
3
17
|
## [0.1.0] - 2026-04-15
|
|
4
18
|
|
|
5
19
|
- Initial release
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 << "#
|
|
48
|
-
out << "This file is the complete documentation for the
|
|
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) }
|
data/docker-compose.yml
ADDED
|
@@ -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)/`).
|
|
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
|
-
|
|
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.
|
data/docs/accounting/user.md
CHANGED
|
@@ -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)/`).
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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.**
|
|
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
|
|
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
|
data/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.**
|
|
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
|
|
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
|
|
23
|
+
## What Xero Kiwi does automatically
|
|
24
24
|
|
|
25
|
-
|
|
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,"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
216
|
-
doesn't proactively throttle
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
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.
|
data/docs/throttling.md
ADDED
|
@@ -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
|
|
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),
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
data/lib/xero-kiwi.rb
ADDED
data/lib/xero_kiwi/client.rb
CHANGED
|
@@ -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.
|
|
526
|
-
#
|
|
527
|
+
# 3. Throttle — blocks 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"
|
data/lib/xero_kiwi/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Douglas Greyling
|
|
@@ -65,7 +65,21 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '2.7'
|
|
68
|
-
|
|
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'
|
|
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
|
|
71
85
|
than the plumbing.
|
|
@@ -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,7 +115,9 @@ 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
|
|
120
|
+
- lib/xero-kiwi.rb
|
|
104
121
|
- lib/xero_kiwi.rb
|
|
105
122
|
- lib/xero_kiwi/accounting/address.rb
|
|
106
123
|
- lib/xero_kiwi/accounting/allocation.rb
|
|
@@ -127,6 +144,10 @@ files:
|
|
|
127
144
|
- lib/xero_kiwi/oauth.rb
|
|
128
145
|
- lib/xero_kiwi/oauth/id_token.rb
|
|
129
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
|
|
130
151
|
- lib/xero_kiwi/token.rb
|
|
131
152
|
- lib/xero_kiwi/token_refresher.rb
|
|
132
153
|
- lib/xero_kiwi/version.rb
|