xero-kiwi 0.10.0 → 0.12.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 +42 -0
- data/docs/accounting/tracking-category.md +15 -5
- data/docs/client.md +32 -0
- data/docs/errors.md +36 -3
- data/docs/oauth.md +40 -1
- data/docs/querying.md +12 -3
- data/docs/retries-and-rate-limits.md +4 -2
- data/docs/tokens.md +31 -1
- data/lib/xero_kiwi/client.rb +36 -15
- data/lib/xero_kiwi/errors.rb +1 -0
- data/lib/xero_kiwi/identity.rb +17 -6
- data/lib/xero_kiwi/oauth/id_token.rb +26 -7
- data/lib/xero_kiwi/oauth.rb +11 -2
- data/lib/xero_kiwi/token_refresher.rb +10 -2
- data/lib/xero_kiwi/version.rb +1 -1
- data/llms-full.txt +170 -15
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5aeae0acf1024528b094ffc690323179bd336c9817bef6a897258b5304f66844
|
|
4
|
+
data.tar.gz: 2ab0071a933a39e93a10ef7ed7658b354e2043cc753441c65491443fff5e22a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b4c8cdd72c20abdd5d377edbb125b53466d6ebb907a8f2949fec962d20d432226c39e21d23c3053f39bdf7f935678ff2a10cc0240db8f32eb07f5d8d7133ff5
|
|
7
|
+
data.tar.gz: 7b19c8922f563d1a51b599f6379a92d289a19c43b5580282a0cd348561cb1f8f0786e245e0490fbe3ad5cf31c462f49e37cf37cc9996f91c003e0721d80c6e55
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.12.0] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- **Timeouts and failed connections now raise `XeroKiwi::ConnectionError`.** They used to escape as `Faraday::TimeoutError`, `Faraday::ConnectionFailed` or `Faraday::SSLError`. None of these is a `XeroKiwi::Error`, so `rescue XeroKiwi::Error` missed them. All three are now wrapped in `XeroKiwi::ConnectionError < XeroKiwi::Error`, with the original as `cause`. The client's retry middleware still sees and retries Faraday's own errors; the wrap only happens once it gives up.
|
|
8
|
+
|
|
9
|
+
Upgrading: a `rescue` of any of those three Faraday errors around a kiwi call no longer matches. Rescue `XeroKiwi::ConnectionError`, and read `e.cause` if you need to tell them apart.
|
|
10
|
+
|
|
11
|
+
`XeroKiwi::OAuth::IDToken.verify` without `jwks:` is unchanged: it still raises `IDTokenError` for any failure to fetch the JWKS.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Every HTTP call now has a timeout.** There were none, so each call used Net::HTTP's defaults of 60 seconds to connect, 60 to read and 60 to write. A consumer runs the code exchange and the ID token check inside a web request, and retries the exchange 3 times, so a slow identity.xero.com held a server thread for minutes. The defaults are now:
|
|
16
|
+
|
|
17
|
+
| Host | Used by | Connect | Read | Write |
|
|
18
|
+
|------|---------|---------|------|-------|
|
|
19
|
+
| identity.xero.com | `OAuth`, `TokenRefresher`, `IDToken.verify` | 5s | 10s | 10s |
|
|
20
|
+
| api.xero.com | `Client` | 5s | 60s | 10s |
|
|
21
|
+
|
|
22
|
+
The API read stays at 60 seconds because a page of 1,000 invoices can be slow. The client retries a timeout up to 4 times, so one hung API request can still take more than 5 minutes to fail. Lower `timeout:` and `retry_options: { max: ... }` together where that matters.
|
|
23
|
+
|
|
24
|
+
`IDToken.verify` without `jwks:` also stops Net::HTTP retrying a timed-out JWKS fetch, which doubled its wait.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`open_timeout:`, `timeout:` and `write_timeout:`**, in seconds, on `XeroKiwi::OAuth.new`, `XeroKiwi::TokenRefresher.new`, `XeroKiwi::OAuth::IDToken.verify` and `XeroKiwi::Client.new`. `timeout:` is the read timeout, under the name Faraday gives it. The refresh and revoke calls a `Client` makes use the identity defaults, not the client's own values.
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- The errors doc said everything kiwi raises is a `XeroKiwi::Error`. One Faraday error still escapes: `Faraday::ParsingError`, when a response says it is JSON but does not parse. The doc now says so.
|
|
33
|
+
|
|
34
|
+
## [0.11.0] - 2026-10-08
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`include_archived:`** on `tracking_categories` / `each_tracking_category` / `each_tracking_category_page`, matching the contacts methods. Maps to Xero's `includeArchived`. Without it, Xero leaves out archived categories *and* the archived options under active ones, so a sync that mirrors the list — deleting whatever Xero didn't return — deletes every archived option, and historical invoices tagged with one stop resolving. A consumer's legacy XML client sent the flag; on 0.10.0 passing it raised `ArgumentError: unknown keyword: :include_archived`.
|
|
39
|
+
|
|
40
|
+
### Documentation
|
|
41
|
+
|
|
42
|
+
- The tracking categories "lookup from option ID" example now passes `include_archived: true`. Without it, `options.fetch` raises `KeyError` on any line item tagged with an archived option.
|
|
43
|
+
- The "Ignoring archived categories" example filtered a list that could never contain one, since Xero leaves archived categories out by default. It now says so, and shows the filter on a list fetched with `include_archived: true`.
|
|
44
|
+
|
|
3
45
|
## [0.10.0] - 2026-09-29
|
|
4
46
|
|
|
5
47
|
### Breaking
|
|
@@ -52,8 +52,11 @@ categories.first.options.map(&:name) # => ["Eastside", "Westside"]
|
|
|
52
52
|
the `Xero-Tenant-Id` header set, and returns a `XeroKiwi::Page` of
|
|
53
53
|
`TrackingCategory` objects.
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
Archived categories, and archived options under active ones, are left out
|
|
56
|
+
unless you pass `include_archived: true`. See [Querying](../querying.md).
|
|
57
|
+
|
|
58
|
+
This endpoint isn't paged — Xero returns every category in one response, and
|
|
59
|
+
an organisation has at most two active ones. `each_tracking_category` exists so all ten list resources behave
|
|
57
60
|
consistently, but there's no reason to reach for it over the plain call.
|
|
58
61
|
|
|
59
62
|
## Fetching a single category
|
|
@@ -139,10 +142,12 @@ so it falls back to structural equality — every attribute must match.
|
|
|
139
142
|
### Building a lookup from option ID to names
|
|
140
143
|
|
|
141
144
|
Line items carry only IDs and denormalised names; if you need the canonical
|
|
142
|
-
option list, fetch the definitions once per sync and index them.
|
|
145
|
+
option list, fetch the definitions once per sync and index them. Include
|
|
146
|
+
archived ones, or a line item tagged with an archived option raises
|
|
147
|
+
`KeyError`.
|
|
143
148
|
|
|
144
149
|
```ruby
|
|
145
|
-
options = client.tracking_categories(tenant_id).flat_map do |category|
|
|
150
|
+
options = client.tracking_categories(tenant_id, include_archived: true).flat_map do |category|
|
|
146
151
|
category.options.map { |option| [option.tracking_option_id, [category.name, option.name]] }
|
|
147
152
|
end.to_h
|
|
148
153
|
|
|
@@ -156,6 +161,11 @@ end
|
|
|
156
161
|
|
|
157
162
|
### Ignoring archived categories
|
|
158
163
|
|
|
164
|
+
Xero leaves archived categories out unless you ask for them, so a plain
|
|
165
|
+
`client.tracking_categories(tenant_id)` already returns only active ones.
|
|
166
|
+
Filter when you fetched archived ones too, for a lookup like the one above:
|
|
167
|
+
|
|
159
168
|
```ruby
|
|
160
|
-
|
|
169
|
+
categories = client.tracking_categories(tenant_id, include_archived: true)
|
|
170
|
+
active = categories.select(&:active?)
|
|
161
171
|
```
|
data/docs/client.md
CHANGED
|
@@ -44,6 +44,9 @@ you. See [Tokens](tokens.md) for the full refresh story.
|
|
|
44
44
|
| `throttle:` | Limiter | No | `XeroKiwi.default_throttle`, else none | Proactive per-tenant rate limiting. See [Throttling](throttling.md). |
|
|
45
45
|
| `page_size:` | `Integer` | No | `nil` (Xero's own default of 100) | Default `pageSize` for every list call. Per-call `page_size:` overrides it. See [Querying](querying.md). |
|
|
46
46
|
| `retain_raw:` | `Boolean` | No | `false` | Keep Xero's untouched response hash on each resource, readable via `#raw`. See below. |
|
|
47
|
+
| `open_timeout:` | `Numeric` | No | `5` | Seconds to wait for a connection to api.xero.com. See [Timeouts](#timeouts). |
|
|
48
|
+
| `timeout:` | `Numeric` | No | `60` | Seconds to wait for each read of the response. Longer than the identity default, because a page of 1,000 invoices can be slow. |
|
|
49
|
+
| `write_timeout:` | `Numeric` | No | `10` | Seconds to wait for each write of the request. |
|
|
47
50
|
|
|
48
51
|
### `retain_raw:` and `#raw`
|
|
49
52
|
|
|
@@ -371,6 +374,35 @@ client = XeroKiwi::Client.new(
|
|
|
371
374
|
See [retries and rate limits](retries-and-rate-limits.md) for the full
|
|
372
375
|
configuration reference and which keys you can override.
|
|
373
376
|
|
|
377
|
+
## Timeouts
|
|
378
|
+
|
|
379
|
+
A request that hits `open_timeout:`, `timeout:` or `write_timeout:` is
|
|
380
|
+
retried like any other transport failure. Once the retries are exhausted, the
|
|
381
|
+
client raises `XeroKiwi::ConnectionError`, with the last
|
|
382
|
+
`Faraday::TimeoutError` or `Faraday::ConnectionFailed` as its `cause`. A
|
|
383
|
+
failed TLS handshake is not retried: it raises `XeroKiwi::ConnectionError` at
|
|
384
|
+
once, with `Faraday::SSLError` as its `cause`.
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
client = XeroKiwi::Client.new(
|
|
388
|
+
access_token: "...",
|
|
389
|
+
open_timeout: 2,
|
|
390
|
+
timeout: 30
|
|
391
|
+
)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Retries multiply the wait.** With the default `max: 4`, one hung request
|
|
395
|
+
can wait for 5 reads of 60 seconds, plus the backoff between them: more than
|
|
396
|
+
5 minutes. Where that matters, such as inside a web request, lower `timeout:`
|
|
397
|
+
and `retry_options: { max: ... }` together.
|
|
398
|
+
|
|
399
|
+
`timeout:` applies to each read from the socket, not to the whole request, so
|
|
400
|
+
a response that keeps arriving slowly can take longer in total.
|
|
401
|
+
|
|
402
|
+
**Refresh and revoke use the identity timeouts.** Those calls go to
|
|
403
|
+
identity.xero.com, so the client's own values do not apply to them. See
|
|
404
|
+
[Tokens](tokens.md#timeouts).
|
|
405
|
+
|
|
374
406
|
## Thread safety
|
|
375
407
|
|
|
376
408
|
A single client can safely be shared across threads. The internals are
|
data/docs/errors.md
CHANGED
|
@@ -16,6 +16,7 @@ StandardError
|
|
|
16
16
|
│ ├─ XeroKiwi::ClientError (other 4xx)
|
|
17
17
|
│ ├─ XeroKiwi::ServerError (5xx)
|
|
18
18
|
│ └─ XeroKiwi::RateLimitError (429, with retry_after + problem)
|
|
19
|
+
├─ XeroKiwi::ConnectionError (timeout or failed connection)
|
|
19
20
|
├─ XeroKiwi::OAuth::StateMismatchError (CSRF check failed)
|
|
20
21
|
└─ XeroKiwi::OAuth::IDTokenError (ID token JWT verification failed)
|
|
21
22
|
```
|
|
@@ -23,12 +24,16 @@ StandardError
|
|
|
23
24
|
A few things worth noticing about the shape:
|
|
24
25
|
|
|
25
26
|
- **Everything Xero Kiwi raises** descends from `XeroKiwi::Error`. If you only want
|
|
26
|
-
one rescue clause for "the Xero integration broke," catch this.
|
|
27
|
+
one rescue clause for "the Xero integration broke," catch this. The one
|
|
28
|
+
exception is a response that says it is JSON but does not parse, which
|
|
29
|
+
raises Faraday's own `Faraday::ParsingError`.
|
|
27
30
|
- **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
|
|
28
31
|
and `body` attributes you can inspect.
|
|
29
32
|
- **OAuth-specific errors** for state mismatch and ID token verification
|
|
30
33
|
descend from `XeroKiwi::Error` directly (not `APIError`) because they don't
|
|
31
34
|
come from an HTTP response — they're local validation failures.
|
|
35
|
+
- **`XeroKiwi::ConnectionError`** descends from `XeroKiwi::Error` directly
|
|
36
|
+
too. No response arrived, so there is no status or body to carry.
|
|
32
37
|
- **`XeroKiwi::TokenRefreshError`** and **`XeroKiwi::OAuth::CodeExchangeError`** are
|
|
33
38
|
both `AuthenticationError` subclasses, because the practical recovery is
|
|
34
39
|
the same as for any 401: the user needs to re-authorise.
|
|
@@ -136,6 +141,30 @@ Extra attributes beyond `APIError`:
|
|
|
136
141
|
Sidekiq job can re-enqueue itself with that delay rather than hammering
|
|
137
142
|
Xero.
|
|
138
143
|
|
|
144
|
+
### `XeroKiwi::ConnectionError`
|
|
145
|
+
|
|
146
|
+
Raised when a request hits its `open_timeout:`, `timeout:` or
|
|
147
|
+
`write_timeout:`, or when the connection fails (refused, reset, the host
|
|
148
|
+
does not resolve, or the TLS handshake fails). `XeroKiwi::Client` raises it
|
|
149
|
+
only after its retries are exhausted, except for a TLS failure, which it
|
|
150
|
+
does not retry. Identity calls (code exchange, client credentials, refresh,
|
|
151
|
+
revoke and the JWKS fetch) are not retried.
|
|
152
|
+
|
|
153
|
+
The original `Faraday::TimeoutError`, `Faraday::ConnectionFailed` or
|
|
154
|
+
`Faraday::SSLError` is the error's `cause`:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
rescue XeroKiwi::ConnectionError => e
|
|
158
|
+
e.cause # => #<Faraday::TimeoutError: Net::ReadTimeout ...>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`XeroKiwi::OAuth::IDToken.verify` without `jwks:` is the one exception: it
|
|
162
|
+
raises `XeroKiwi::OAuth::IDTokenError` for any failure to fetch the JWKS,
|
|
163
|
+
timeouts included.
|
|
164
|
+
|
|
165
|
+
See [Client — Timeouts](client.md#timeouts) and [OAuth — Timeouts](oauth.md#timeouts)
|
|
166
|
+
for the limits and their defaults.
|
|
167
|
+
|
|
139
168
|
### `XeroKiwi::OAuth::StateMismatchError`
|
|
140
169
|
|
|
141
170
|
Raised by `XeroKiwi::OAuth.verify_state!` when the `state` parameter Xero
|
|
@@ -156,7 +185,9 @@ the ID token JWT can't be verified. Causes:
|
|
|
156
185
|
- Wrong audience.
|
|
157
186
|
- Token expired.
|
|
158
187
|
- Nonce mismatch (when nonce verification was requested).
|
|
159
|
-
- Network failure fetching JWKS.
|
|
188
|
+
- Network failure fetching JWKS, through `IDToken.verify` without `jwks:`.
|
|
189
|
+
Through `oauth.verify_id_token`, a network failure raises
|
|
190
|
+
`XeroKiwi::ConnectionError` instead.
|
|
160
191
|
|
|
161
192
|
The error message has a brief description of which check failed (e.g.
|
|
162
193
|
`"ID token verification failed: Signature verification failed"`,
|
|
@@ -223,10 +254,12 @@ rescue XeroKiwi::OAuth::CodeExchangeError
|
|
|
223
254
|
redirect_to root_path, alert: "Could not complete Xero authorisation"
|
|
224
255
|
rescue XeroKiwi::OAuth::IDTokenError
|
|
225
256
|
redirect_to root_path, alert: "Could not verify Xero identity"
|
|
257
|
+
rescue XeroKiwi::ConnectionError
|
|
258
|
+
redirect_to root_path, alert: "Xero did not respond. Please try again."
|
|
226
259
|
end
|
|
227
260
|
```
|
|
228
261
|
|
|
229
|
-
|
|
262
|
+
Four separate rescue clauses give you four different user-facing
|
|
230
263
|
messages, which is usually what you want for an OAuth callback — different
|
|
231
264
|
failures imply different user actions.
|
|
232
265
|
|
data/docs/oauth.md
CHANGED
|
@@ -40,6 +40,9 @@ oauth = XeroKiwi::OAuth.new(
|
|
|
40
40
|
| `client_secret:` | `String` | Yes | — | Your Xero app's client secret |
|
|
41
41
|
| `redirect_uri:` | `String` | Conditional | `nil` | Required for `authorization_url` and `exchange_code`. **Optional** if you only use this instance for `revoke_token` or `verify_id_token`. |
|
|
42
42
|
| `adapter:` | Faraday adapter | No | `Faraday.default_adapter` | The HTTP adapter for token-endpoint and JWKS calls |
|
|
43
|
+
| `open_timeout:` | `Numeric` | No | `5` | Seconds to wait for a connection to identity.xero.com. See [Timeouts](#timeouts). |
|
|
44
|
+
| `timeout:` | `Numeric` | No | `10` | Seconds to wait for each read of the response. |
|
|
45
|
+
| `write_timeout:` | `Numeric` | No | `10` | Seconds to wait for each write of the request. |
|
|
43
46
|
|
|
44
47
|
### Why `redirect_uri:` is optional
|
|
45
48
|
|
|
@@ -58,6 +61,32 @@ fake redirect URL. Pass `redirect_uri: nil` (or just omit it). If you then
|
|
|
58
61
|
*do* call `authorization_url` or `exchange_code`, you'll get a clear
|
|
59
62
|
`ArgumentError` at call time rather than a confusing 400 from Xero.
|
|
60
63
|
|
|
64
|
+
### Timeouts
|
|
65
|
+
|
|
66
|
+
Every call the instance makes — `exchange_code`, `client_credentials_token`,
|
|
67
|
+
`revoke_token` and the JWKS fetch behind `verify_id_token` — gives up after
|
|
68
|
+
these limits and raises `XeroKiwi::ConnectionError`. Faraday's
|
|
69
|
+
`Faraday::TimeoutError` or `Faraday::ConnectionFailed` is its `cause`.
|
|
70
|
+
|
|
71
|
+
Without them, each call would use Net::HTTP's defaults of 60 seconds to
|
|
72
|
+
connect, 60 to read and 60 to write. The code exchange and the ID token check
|
|
73
|
+
usually run inside a web request, so a slow identity.xero.com would hold that
|
|
74
|
+
request, and its server thread, for a minute or more.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
oauth = XeroKiwi::OAuth.new(
|
|
78
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
79
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
|
|
80
|
+
redirect_uri: "https://app.example.com/xero/callback",
|
|
81
|
+
open_timeout: 2,
|
|
82
|
+
timeout: 5
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Identity calls are not retried, so the limits are close to the whole wait.
|
|
87
|
+
`timeout:` applies to each read from the socket, so a response that keeps
|
|
88
|
+
arriving slowly can take longer in total.
|
|
89
|
+
|
|
61
90
|
## Step 1: building the authorisation URL
|
|
62
91
|
|
|
63
92
|
```ruby
|
|
@@ -263,7 +292,7 @@ id_token, and scope. See [Tokens](tokens.md) for the full reference.
|
|
|
263
292
|
| Wrong `redirect_uri` (must match the one used at authorise time) | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
264
293
|
| PKCE verifier mismatch | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
265
294
|
| Wrong client credentials | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
266
|
-
|
|
|
295
|
+
| Timeout, or no connection to Xero | `XeroKiwi::ConnectionError`, with Faraday's error as its `cause` |
|
|
267
296
|
|
|
268
297
|
`XeroKiwi::OAuth::CodeExchangeError` inherits from `XeroKiwi::AuthenticationError`,
|
|
269
298
|
so you can catch the broader class if you don't care about the distinction.
|
|
@@ -312,6 +341,15 @@ The standalone class method **fetches JWKS fresh on every call** via
|
|
|
312
341
|
`Net::HTTP`. Fine for one-off use, wasteful in a hot loop. If you find
|
|
313
342
|
yourself calling it repeatedly, switch to the OAuth instance method.
|
|
314
343
|
|
|
344
|
+
It takes the same `open_timeout:`, `timeout:` and `write_timeout:` options
|
|
345
|
+
as `OAuth.new`, with the same defaults. Unlike the instance method, it raises
|
|
346
|
+
`XeroKiwi::OAuth::IDTokenError` when the fetch times out, as it does for any
|
|
347
|
+
other failure to fetch the JWKS.
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
XeroKiwi::OAuth::IDToken.verify(id_token, client_id: "...", timeout: 5)
|
|
351
|
+
```
|
|
352
|
+
|
|
315
353
|
You can also inject your own JWKS provider:
|
|
316
354
|
|
|
317
355
|
```ruby
|
|
@@ -443,6 +481,7 @@ auto-refresh paths stay out of the way.
|
|
|
443
481
|
|-------|-----------|
|
|
444
482
|
| Wrong `client_id` / `client_secret` | `XeroKiwi::OAuth::ClientCredentialsError` |
|
|
445
483
|
| Scope the app isn't authorised for | `XeroKiwi::OAuth::ClientCredentialsError` |
|
|
484
|
+
| Timeout, or no connection to Xero | `XeroKiwi::ConnectionError`, with Faraday's error as its `cause` |
|
|
446
485
|
|
|
447
486
|
`ClientCredentialsError` subclasses `XeroKiwi::AuthenticationError`, so an
|
|
448
487
|
existing `rescue XeroKiwi::AuthenticationError` still catches it. There is
|
data/docs/querying.md
CHANGED
|
@@ -233,14 +233,18 @@ exception, no special flag. An empty page after `modified_since:` is
|
|
|
233
233
|
indistinguishable from a filter that matched nothing (intentional — the
|
|
234
234
|
caller can treat them identically).
|
|
235
235
|
|
|
236
|
-
## `include_archived:` — archived contacts
|
|
236
|
+
## `include_archived:` — archived contacts and tracking categories
|
|
237
237
|
|
|
238
|
-
Contacts only. Maps to Xero's `includeArchived`
|
|
239
|
-
archived
|
|
238
|
+
Contacts and tracking categories only. Maps to Xero's `includeArchived`
|
|
239
|
+
query param, which returns archived records **alongside** active ones in
|
|
240
|
+
the same pass.
|
|
240
241
|
|
|
241
242
|
```ruby
|
|
242
243
|
client.contacts(tenant, include_archived: true)
|
|
243
244
|
client.each_contact(tenant, include_archived: true) { |contact| … }
|
|
245
|
+
|
|
246
|
+
client.tracking_categories(tenant, include_archived: true)
|
|
247
|
+
client.each_tracking_category(tenant, include_archived: true) { |category| … }
|
|
244
248
|
```
|
|
245
249
|
|
|
246
250
|
This is not the same as filtering on `contact_status`. A
|
|
@@ -252,6 +256,11 @@ It matters beyond contacts themselves: Xero keeps serving archived
|
|
|
252
256
|
contacts as members of contact groups, so without this you can't tell
|
|
253
257
|
which group members are archived locally.
|
|
254
258
|
|
|
259
|
+
For tracking categories it reaches the options too. Without it, Xero leaves
|
|
260
|
+
out archived categories *and* the archived options under active ones. Line
|
|
261
|
+
items keep pointing at an archived option, so a sync that mirrors the list
|
|
262
|
+
without this flag deletes options its invoices still refer to.
|
|
263
|
+
|
|
255
264
|
## Combining everything
|
|
256
265
|
|
|
257
266
|
Mix and match freely:
|
|
@@ -175,10 +175,12 @@ code, catch `XeroKiwi::ServerError` and handle it explicitly.
|
|
|
175
175
|
|----------|-----------------|
|
|
176
176
|
| All retries returned 429 | `XeroKiwi::RateLimitError` (with `retry_after` and `problem` attributes) |
|
|
177
177
|
| All retries returned 502/503/504 | `XeroKiwi::ServerError` |
|
|
178
|
-
| All retries failed at the transport level |
|
|
178
|
+
| All retries failed at the transport level | `XeroKiwi::ConnectionError`, with the last `Faraday::ConnectionFailed` / `Faraday::TimeoutError` as its `cause` |
|
|
179
179
|
|
|
180
180
|
The retried request count includes the original attempt, so `max: 4` means
|
|
181
|
-
**up to 5 total HTTP requests** before giving up.
|
|
181
|
+
**up to 5 total HTTP requests** before giving up. Each of them can wait the
|
|
182
|
+
full read timeout, so a hung endpoint takes up to 5 times as long to fail.
|
|
183
|
+
See [Client — Timeouts](client.md#timeouts).
|
|
182
184
|
|
|
183
185
|
## Customising the retry policy
|
|
184
186
|
|
data/docs/tokens.md
CHANGED
|
@@ -145,6 +145,34 @@ else
|
|
|
145
145
|
end
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Timeouts
|
|
149
|
+
|
|
150
|
+
A refresh goes to identity.xero.com, not api.xero.com, so it uses the
|
|
151
|
+
identity timeouts: 5 seconds to connect, 10 to read and 10 to write. The
|
|
152
|
+
client's own `timeout:` does not apply to it. When identity.xero.com does not
|
|
153
|
+
answer in time, the refresh raises `XeroKiwi::ConnectionError` rather than
|
|
154
|
+
holding the thread for Net::HTTP's default of 60 seconds.
|
|
155
|
+
|
|
156
|
+
`XeroKiwi::TokenRefresher` takes the same options as `XeroKiwi::OAuth.new`
|
|
157
|
+
if you use it directly:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
refresher = XeroKiwi::TokenRefresher.new(
|
|
161
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
162
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
|
|
163
|
+
open_timeout: 2,
|
|
164
|
+
timeout: 5
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
refresher.refresh(refresh_token: stored_refresh_token)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| Option | Default | Purpose |
|
|
171
|
+
|--------|---------|---------|
|
|
172
|
+
| `open_timeout:` | `5` | Seconds to wait for a connection. |
|
|
173
|
+
| `timeout:` | `10` | Seconds to wait for each read of the response. |
|
|
174
|
+
| `write_timeout:` | `10` | Seconds to wait for each write of the request. |
|
|
175
|
+
|
|
148
176
|
## The `on_token_refresh` callback
|
|
149
177
|
|
|
150
178
|
This is the most important integration point in the gem. Every time the
|
|
@@ -275,7 +303,9 @@ This:
|
|
|
275
303
|
current refresh token.
|
|
276
304
|
2. Returns `true` on success.
|
|
277
305
|
3. Raises `XeroKiwi::TokenRefreshError` if the client has no refresh capability,
|
|
278
|
-
|
|
306
|
+
`XeroKiwi::AuthenticationError` if Xero rejects the revoke, or
|
|
307
|
+
`XeroKiwi::ConnectionError` if identity.xero.com does not answer within the
|
|
308
|
+
[identity timeouts](#timeouts).
|
|
279
309
|
|
|
280
310
|
After revocation, **treat the client as dead.** Subsequent API calls will
|
|
281
311
|
401, and reactive refresh will fail because the refresh token is gone too.
|
data/lib/xero_kiwi/client.rb
CHANGED
|
@@ -52,6 +52,10 @@ module XeroKiwi
|
|
|
52
52
|
]
|
|
53
53
|
}.freeze
|
|
54
54
|
|
|
55
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
56
|
+
DEFAULT_READ_TIMEOUT = 60
|
|
57
|
+
DEFAULT_WRITE_TIMEOUT = 10
|
|
58
|
+
|
|
55
59
|
attr_reader :token
|
|
56
60
|
|
|
57
61
|
def initialize(
|
|
@@ -66,7 +70,10 @@ module XeroKiwi
|
|
|
66
70
|
retry_options: {},
|
|
67
71
|
throttle: nil,
|
|
68
72
|
page_size: nil,
|
|
69
|
-
retain_raw: false
|
|
73
|
+
retain_raw: false,
|
|
74
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
75
|
+
timeout: DEFAULT_READ_TIMEOUT,
|
|
76
|
+
write_timeout: DEFAULT_WRITE_TIMEOUT
|
|
70
77
|
)
|
|
71
78
|
@token = Token.new(access_token: access_token, refresh_token: refresh_token, expires_at: expires_at)
|
|
72
79
|
@client_id = client_id
|
|
@@ -75,6 +82,7 @@ module XeroKiwi
|
|
|
75
82
|
@adapter = adapter
|
|
76
83
|
@user_agent = user_agent
|
|
77
84
|
@retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
|
|
85
|
+
@timeouts = { open_timeout: open_timeout, timeout: timeout, write_timeout: write_timeout }
|
|
78
86
|
@throttle = throttle || XeroKiwi.default_throttle || Throttle::NullLimiter.new
|
|
79
87
|
@page_size = page_size
|
|
80
88
|
@retain_raw = retain_raw
|
|
@@ -617,7 +625,11 @@ module XeroKiwi
|
|
|
617
625
|
# Fetches the Tracking Categories for the given tenant. Accepts a tenant-id
|
|
618
626
|
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
619
627
|
# See: https://developer.xero.com/documentation/api/accounting/trackingcategories
|
|
620
|
-
def tracking_categories(tenant_id, where: nil, order: nil, page_size: nil, page: nil, modified_since: nil)
|
|
628
|
+
def tracking_categories(tenant_id, where: nil, order: nil, page_size: nil, include_archived: nil, page: nil, modified_since: nil)
|
|
629
|
+
extra_params = {}
|
|
630
|
+
|
|
631
|
+
extra_params["includeArchived"] = include_archived unless include_archived.nil?
|
|
632
|
+
|
|
621
633
|
list_request(
|
|
622
634
|
path: "/api.xro/2.0/TrackingCategories",
|
|
623
635
|
tenant_id: tenant_id,
|
|
@@ -626,27 +638,28 @@ module XeroKiwi
|
|
|
626
638
|
order: order,
|
|
627
639
|
page: page,
|
|
628
640
|
page_size: page_size,
|
|
629
|
-
modified_since: modified_since
|
|
641
|
+
modified_since: modified_since,
|
|
642
|
+
extra_params: extra_params
|
|
630
643
|
)
|
|
631
644
|
end
|
|
632
645
|
|
|
633
646
|
# Yields every Tracking Category across all pages, driving `#tracking_categories` with
|
|
634
647
|
# `page:` until an empty or short page signals the end. Returns an
|
|
635
648
|
# Enumerator when no block is given.
|
|
636
|
-
def each_tracking_category(tenant_id, where: nil, order: nil, page_size: nil, start_page: 1, modified_since: nil, &block)
|
|
637
|
-
return to_enum(:each_tracking_category, tenant_id, where: where, order: order, page_size: page_size, start_page: start_page, modified_since: modified_since) unless block
|
|
649
|
+
def each_tracking_category(tenant_id, where: nil, order: nil, page_size: nil, include_archived: nil, start_page: 1, modified_since: nil, &block)
|
|
650
|
+
return to_enum(:each_tracking_category, tenant_id, where: where, order: order, page_size: page_size, include_archived: include_archived, start_page: start_page, modified_since: modified_since) unless block
|
|
638
651
|
|
|
639
|
-
each_tracking_category_page(tenant_id, where: where, order: order, page_size: page_size, start_page: start_page, modified_since: modified_since) { |pg| pg.each(&block) }
|
|
652
|
+
each_tracking_category_page(tenant_id, where: where, order: order, page_size: page_size, include_archived: include_archived, start_page: start_page, modified_since: modified_since) { |pg| pg.each(&block) }
|
|
640
653
|
end
|
|
641
654
|
|
|
642
655
|
# Same walk as `#each_tracking_category`, but yields each XeroKiwi::Page rather
|
|
643
656
|
# than its items. Pair `page.page` with `start_page:` to make a sync
|
|
644
657
|
# resumable: record the page number in the same transaction that stores
|
|
645
658
|
# the rows, and a crash can't leave a marker ahead of the data.
|
|
646
|
-
def each_tracking_category_page(tenant_id, where: nil, order: nil, page_size: nil, start_page: 1, modified_since: nil, &block)
|
|
647
|
-
return to_enum(:each_tracking_category_page, tenant_id, where: where, order: order, page_size: page_size, start_page: start_page, modified_since: modified_since) unless block
|
|
659
|
+
def each_tracking_category_page(tenant_id, where: nil, order: nil, page_size: nil, include_archived: nil, start_page: 1, modified_since: nil, &block)
|
|
660
|
+
return to_enum(:each_tracking_category_page, tenant_id, where: where, order: order, page_size: page_size, include_archived: include_archived, start_page: start_page, modified_since: modified_since) unless block
|
|
648
661
|
|
|
649
|
-
walk_pages(:tracking_categories, tenant_id, where: where, order: order, page_size: page_size, start_page: start_page, modified_since: modified_since, &block)
|
|
662
|
+
walk_pages(:tracking_categories, tenant_id, where: where, order: order, page_size: page_size, include_archived: include_archived, start_page: start_page, modified_since: modified_since, &block)
|
|
650
663
|
end
|
|
651
664
|
|
|
652
665
|
# Fetches a single Tracking Category by ID for the given tenant. Accepts a
|
|
@@ -877,8 +890,9 @@ module XeroKiwi
|
|
|
877
890
|
# Middleware order matters. Outbound runs top-to-bottom; inbound runs in
|
|
878
891
|
# reverse. We want:
|
|
879
892
|
#
|
|
880
|
-
# 1. ResponseHandler (outermost) — converts the FINAL response status
|
|
881
|
-
# a XeroKiwi exception, *after*
|
|
893
|
+
# 1. ResponseHandler (outermost) — converts the FINAL response status, or
|
|
894
|
+
# the final transport failure, into a XeroKiwi exception, *after*
|
|
895
|
+
# retries have been exhausted.
|
|
882
896
|
# 2. Retry — retries on 429/503 (respecting Retry-After) and on transport
|
|
883
897
|
# exceptions.
|
|
884
898
|
# 3. RateLimitCapture — records Xero's quota headers. Below Retry so
|
|
@@ -894,7 +908,7 @@ module XeroKiwi
|
|
|
894
908
|
# gets retried by Faraday before we ever raise RateLimitError, and the
|
|
895
909
|
# exception only fires once we've truly given up.
|
|
896
910
|
def build_http
|
|
897
|
-
Faraday.new(url: BASE_URL) do |f|
|
|
911
|
+
Faraday.new(url: BASE_URL, request: @timeouts) do |f|
|
|
898
912
|
f.use ResponseHandler
|
|
899
913
|
f.request :retry, @retry_options
|
|
900
914
|
f.use RateLimitCapture, @rate_limits
|
|
@@ -980,10 +994,17 @@ module XeroKiwi
|
|
|
980
994
|
end
|
|
981
995
|
end
|
|
982
996
|
|
|
983
|
-
# Faraday middleware that maps non-2xx responses
|
|
984
|
-
# hierarchy. Lives outside the retry middleware so it
|
|
985
|
-
# final response
|
|
997
|
+
# Faraday middleware that maps non-2xx responses and transport failures
|
|
998
|
+
# onto our exception hierarchy. Lives outside the retry middleware so it
|
|
999
|
+
# only fires on the final response, and so the retry middleware still
|
|
1000
|
+
# sees Faraday's own errors.
|
|
986
1001
|
class ResponseHandler < Faraday::Middleware
|
|
1002
|
+
def call(env)
|
|
1003
|
+
super
|
|
1004
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
1005
|
+
raise ConnectionError, "Request to #{env.url.host} failed: #{e.message}"
|
|
1006
|
+
end
|
|
1007
|
+
|
|
987
1008
|
def on_complete(env)
|
|
988
1009
|
# 304 Not Modified is a valid response to a conditional GET
|
|
989
1010
|
# (If-Modified-Since). Let it through so the list helper can
|
data/lib/xero_kiwi/errors.rb
CHANGED
|
@@ -16,6 +16,7 @@ module XeroKiwi
|
|
|
16
16
|
class AuthenticationError < APIError; end
|
|
17
17
|
class ClientError < APIError; end
|
|
18
18
|
class ServerError < APIError; end
|
|
19
|
+
class ConnectionError < Error; end
|
|
19
20
|
|
|
20
21
|
# Raised when refreshing the OAuth2 token fails — typically because the
|
|
21
22
|
# refresh token has expired (60 days) or has already been rotated. Callers
|
data/lib/xero_kiwi/identity.rb
CHANGED
|
@@ -17,15 +17,26 @@ module XeroKiwi
|
|
|
17
17
|
JWKS_PATH = "/.well-known/openid-configuration/jwks"
|
|
18
18
|
JWKS_URL = "#{URL}#{JWKS_PATH}".freeze
|
|
19
19
|
|
|
20
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
21
|
+
DEFAULT_READ_TIMEOUT = 10
|
|
22
|
+
DEFAULT_WRITE_TIMEOUT = 10
|
|
23
|
+
|
|
20
24
|
module_function
|
|
21
25
|
|
|
22
26
|
# Builds a Faraday connection configured for the Xero identity host:
|
|
23
|
-
# JSON response parsing and our exception mapping. No retry
|
|
24
|
-
# — token endpoints aren't subject to the same rate limits as
|
|
25
|
-
# and retrying a failed token call usually masks a real
|
|
26
|
-
# problem instead of fixing a transient one.
|
|
27
|
-
def build_http(
|
|
28
|
-
|
|
27
|
+
# timeouts, JSON response parsing and our exception mapping. No retry
|
|
28
|
+
# middleware — token endpoints aren't subject to the same rate limits as
|
|
29
|
+
# the API, and retrying a failed token call usually masks a real
|
|
30
|
+
# configuration problem instead of fixing a transient one.
|
|
31
|
+
def build_http(
|
|
32
|
+
adapter: nil,
|
|
33
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
34
|
+
timeout: DEFAULT_READ_TIMEOUT,
|
|
35
|
+
write_timeout: DEFAULT_WRITE_TIMEOUT
|
|
36
|
+
)
|
|
37
|
+
request = { open_timeout: open_timeout, timeout: timeout, write_timeout: write_timeout }
|
|
38
|
+
|
|
39
|
+
Faraday.new(url: URL, request: request) do |f|
|
|
29
40
|
f.use Client::ResponseHandler
|
|
30
41
|
f.response :json, content_type: /\bjson/
|
|
31
42
|
f.adapter(adapter || Faraday.default_adapter)
|
|
@@ -34,20 +34,29 @@ module XeroKiwi
|
|
|
34
34
|
|
|
35
35
|
attr_reader :claims
|
|
36
36
|
|
|
37
|
-
def self.verify(
|
|
37
|
+
def self.verify(
|
|
38
|
+
id_token,
|
|
39
|
+
client_id:,
|
|
40
|
+
nonce: nil,
|
|
41
|
+
jwks: nil,
|
|
42
|
+
open_timeout: Identity::DEFAULT_OPEN_TIMEOUT,
|
|
43
|
+
timeout: Identity::DEFAULT_READ_TIMEOUT,
|
|
44
|
+
write_timeout: Identity::DEFAULT_WRITE_TIMEOUT
|
|
45
|
+
)
|
|
38
46
|
raise ArgumentError, "id_token is required" if id_token.nil? || id_token.empty?
|
|
39
47
|
raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty?
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
timeouts = { open_timeout: open_timeout, timeout: timeout, write_timeout: write_timeout }
|
|
50
|
+
decoded = decode(id_token, client_id: client_id, jwks: jwks, timeouts: timeouts)
|
|
51
|
+
claims = decoded.first
|
|
43
52
|
verify_nonce!(claims, nonce) if nonce
|
|
44
53
|
new(claims)
|
|
45
54
|
rescue JWT::DecodeError => e
|
|
46
55
|
raise IDTokenError, "ID token verification failed: #{e.message}"
|
|
47
56
|
end
|
|
48
57
|
|
|
49
|
-
def self.decode(id_token, client_id
|
|
50
|
-
jwks_proc = jwks ||
|
|
58
|
+
def self.decode(id_token, client_id:, jwks:, timeouts:)
|
|
59
|
+
jwks_proc = jwks || -> { fetch_jwks_directly(**timeouts) }
|
|
51
60
|
JWT.decode(
|
|
52
61
|
id_token,
|
|
53
62
|
nil,
|
|
@@ -66,8 +75,18 @@ module XeroKiwi
|
|
|
66
75
|
# injected. Net::HTTP keeps this dependency-free at the call site;
|
|
67
76
|
# OAuth#verify_id_token uses an instance-cached Faraday fetcher
|
|
68
77
|
# instead.
|
|
69
|
-
def self.fetch_jwks_directly
|
|
70
|
-
|
|
78
|
+
def self.fetch_jwks_directly(open_timeout:, timeout:, write_timeout:)
|
|
79
|
+
uri = URI(Identity::JWKS_URL)
|
|
80
|
+
options = {
|
|
81
|
+
use_ssl: uri.scheme == "https",
|
|
82
|
+
open_timeout: open_timeout,
|
|
83
|
+
read_timeout: timeout,
|
|
84
|
+
write_timeout: write_timeout,
|
|
85
|
+
# Net::HTTP retries a timed-out GET once, which doubles the wait.
|
|
86
|
+
max_retries: 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
body = Net::HTTP.start(uri.host, uri.port, **options) { |http| http.get(uri.request_uri).body }
|
|
71
90
|
JSON.parse(body).fetch("keys")
|
|
72
91
|
rescue StandardError => e
|
|
73
92
|
raise IDTokenError, "failed to fetch JWKS: #{e.message}"
|
data/lib/xero_kiwi/oauth.rb
CHANGED
|
@@ -101,11 +101,20 @@ module XeroKiwi
|
|
|
101
101
|
# (`authorization_url` and `exchange_code`) but not for `revoke_token`
|
|
102
102
|
# or `verify_id_token`. It's optional at construction time so callers
|
|
103
103
|
# who only need the latter operations don't have to invent a fake URL.
|
|
104
|
-
def initialize(
|
|
104
|
+
def initialize(
|
|
105
|
+
client_id:,
|
|
106
|
+
client_secret:,
|
|
107
|
+
redirect_uri: nil,
|
|
108
|
+
adapter: nil,
|
|
109
|
+
open_timeout: Identity::DEFAULT_OPEN_TIMEOUT,
|
|
110
|
+
timeout: Identity::DEFAULT_READ_TIMEOUT,
|
|
111
|
+
write_timeout: Identity::DEFAULT_WRITE_TIMEOUT
|
|
112
|
+
)
|
|
105
113
|
@client_id = client_id
|
|
106
114
|
@client_secret = client_secret
|
|
107
115
|
@redirect_uri = redirect_uri
|
|
108
116
|
@adapter = adapter
|
|
117
|
+
@timeouts = { open_timeout: open_timeout, timeout: timeout, write_timeout: write_timeout }
|
|
109
118
|
@jwks_mutex = Mutex.new
|
|
110
119
|
@jwks_cache = nil
|
|
111
120
|
@jwks_fetched_at = nil
|
|
@@ -281,7 +290,7 @@ module XeroKiwi
|
|
|
281
290
|
end
|
|
282
291
|
|
|
283
292
|
def http
|
|
284
|
-
@_http ||= Identity.build_http(adapter: @adapter)
|
|
293
|
+
@_http ||= Identity.build_http(adapter: @adapter, **@timeouts)
|
|
285
294
|
end
|
|
286
295
|
end
|
|
287
296
|
end
|
|
@@ -13,10 +13,18 @@ module XeroKiwi
|
|
|
13
13
|
#
|
|
14
14
|
# See: https://developer.xero.com/documentation/guides/oauth2/auth-flow#refreshing-access-and-refresh-tokens
|
|
15
15
|
class TokenRefresher
|
|
16
|
-
def initialize(
|
|
16
|
+
def initialize(
|
|
17
|
+
client_id:,
|
|
18
|
+
client_secret:,
|
|
19
|
+
adapter: nil,
|
|
20
|
+
open_timeout: Identity::DEFAULT_OPEN_TIMEOUT,
|
|
21
|
+
timeout: Identity::DEFAULT_READ_TIMEOUT,
|
|
22
|
+
write_timeout: Identity::DEFAULT_WRITE_TIMEOUT
|
|
23
|
+
)
|
|
17
24
|
@client_id = client_id
|
|
18
25
|
@client_secret = client_secret
|
|
19
26
|
@adapter = adapter
|
|
27
|
+
@timeouts = { open_timeout: open_timeout, timeout: timeout, write_timeout: write_timeout }
|
|
20
28
|
end
|
|
21
29
|
|
|
22
30
|
# Performs the refresh round-trip and returns a fresh XeroKiwi::Token. Raises
|
|
@@ -47,7 +55,7 @@ module XeroKiwi
|
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
def http
|
|
50
|
-
@_http ||= Identity.build_http(adapter: @adapter)
|
|
58
|
+
@_http ||= Identity.build_http(adapter: @adapter, **@timeouts)
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
end
|
data/lib/xero_kiwi/version.rb
CHANGED
data/llms-full.txt
CHANGED
|
@@ -413,6 +413,9 @@ you. See [Tokens](tokens.md) for the full refresh story.
|
|
|
413
413
|
| `throttle:` | Limiter | No | `XeroKiwi.default_throttle`, else none | Proactive per-tenant rate limiting. See [Throttling](throttling.md). |
|
|
414
414
|
| `page_size:` | `Integer` | No | `nil` (Xero's own default of 100) | Default `pageSize` for every list call. Per-call `page_size:` overrides it. See [Querying](querying.md). |
|
|
415
415
|
| `retain_raw:` | `Boolean` | No | `false` | Keep Xero's untouched response hash on each resource, readable via `#raw`. See below. |
|
|
416
|
+
| `open_timeout:` | `Numeric` | No | `5` | Seconds to wait for a connection to api.xero.com. See [Timeouts](#timeouts). |
|
|
417
|
+
| `timeout:` | `Numeric` | No | `60` | Seconds to wait for each read of the response. Longer than the identity default, because a page of 1,000 invoices can be slow. |
|
|
418
|
+
| `write_timeout:` | `Numeric` | No | `10` | Seconds to wait for each write of the request. |
|
|
416
419
|
|
|
417
420
|
### `retain_raw:` and `#raw`
|
|
418
421
|
|
|
@@ -740,6 +743,35 @@ client = XeroKiwi::Client.new(
|
|
|
740
743
|
See [retries and rate limits](retries-and-rate-limits.md) for the full
|
|
741
744
|
configuration reference and which keys you can override.
|
|
742
745
|
|
|
746
|
+
## Timeouts
|
|
747
|
+
|
|
748
|
+
A request that hits `open_timeout:`, `timeout:` or `write_timeout:` is
|
|
749
|
+
retried like any other transport failure. Once the retries are exhausted, the
|
|
750
|
+
client raises `XeroKiwi::ConnectionError`, with the last
|
|
751
|
+
`Faraday::TimeoutError` or `Faraday::ConnectionFailed` as its `cause`. A
|
|
752
|
+
failed TLS handshake is not retried: it raises `XeroKiwi::ConnectionError` at
|
|
753
|
+
once, with `Faraday::SSLError` as its `cause`.
|
|
754
|
+
|
|
755
|
+
```ruby
|
|
756
|
+
client = XeroKiwi::Client.new(
|
|
757
|
+
access_token: "...",
|
|
758
|
+
open_timeout: 2,
|
|
759
|
+
timeout: 30
|
|
760
|
+
)
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
**Retries multiply the wait.** With the default `max: 4`, one hung request
|
|
764
|
+
can wait for 5 reads of 60 seconds, plus the backoff between them: more than
|
|
765
|
+
5 minutes. Where that matters, such as inside a web request, lower `timeout:`
|
|
766
|
+
and `retry_options: { max: ... }` together.
|
|
767
|
+
|
|
768
|
+
`timeout:` applies to each read from the socket, not to the whole request, so
|
|
769
|
+
a response that keeps arriving slowly can take longer in total.
|
|
770
|
+
|
|
771
|
+
**Refresh and revoke use the identity timeouts.** Those calls go to
|
|
772
|
+
identity.xero.com, so the client's own values do not apply to them. See
|
|
773
|
+
[Tokens](tokens.md#timeouts).
|
|
774
|
+
|
|
743
775
|
## Thread safety
|
|
744
776
|
|
|
745
777
|
A single client can safely be shared across threads. The internals are
|
|
@@ -821,6 +853,9 @@ oauth = XeroKiwi::OAuth.new(
|
|
|
821
853
|
| `client_secret:` | `String` | Yes | — | Your Xero app's client secret |
|
|
822
854
|
| `redirect_uri:` | `String` | Conditional | `nil` | Required for `authorization_url` and `exchange_code`. **Optional** if you only use this instance for `revoke_token` or `verify_id_token`. |
|
|
823
855
|
| `adapter:` | Faraday adapter | No | `Faraday.default_adapter` | The HTTP adapter for token-endpoint and JWKS calls |
|
|
856
|
+
| `open_timeout:` | `Numeric` | No | `5` | Seconds to wait for a connection to identity.xero.com. See [Timeouts](#timeouts). |
|
|
857
|
+
| `timeout:` | `Numeric` | No | `10` | Seconds to wait for each read of the response. |
|
|
858
|
+
| `write_timeout:` | `Numeric` | No | `10` | Seconds to wait for each write of the request. |
|
|
824
859
|
|
|
825
860
|
### Why `redirect_uri:` is optional
|
|
826
861
|
|
|
@@ -839,6 +874,32 @@ fake redirect URL. Pass `redirect_uri: nil` (or just omit it). If you then
|
|
|
839
874
|
*do* call `authorization_url` or `exchange_code`, you'll get a clear
|
|
840
875
|
`ArgumentError` at call time rather than a confusing 400 from Xero.
|
|
841
876
|
|
|
877
|
+
### Timeouts
|
|
878
|
+
|
|
879
|
+
Every call the instance makes — `exchange_code`, `client_credentials_token`,
|
|
880
|
+
`revoke_token` and the JWKS fetch behind `verify_id_token` — gives up after
|
|
881
|
+
these limits and raises `XeroKiwi::ConnectionError`. Faraday's
|
|
882
|
+
`Faraday::TimeoutError` or `Faraday::ConnectionFailed` is its `cause`.
|
|
883
|
+
|
|
884
|
+
Without them, each call would use Net::HTTP's defaults of 60 seconds to
|
|
885
|
+
connect, 60 to read and 60 to write. The code exchange and the ID token check
|
|
886
|
+
usually run inside a web request, so a slow identity.xero.com would hold that
|
|
887
|
+
request, and its server thread, for a minute or more.
|
|
888
|
+
|
|
889
|
+
```ruby
|
|
890
|
+
oauth = XeroKiwi::OAuth.new(
|
|
891
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
892
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
|
|
893
|
+
redirect_uri: "https://app.example.com/xero/callback",
|
|
894
|
+
open_timeout: 2,
|
|
895
|
+
timeout: 5
|
|
896
|
+
)
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Identity calls are not retried, so the limits are close to the whole wait.
|
|
900
|
+
`timeout:` applies to each read from the socket, so a response that keeps
|
|
901
|
+
arriving slowly can take longer in total.
|
|
902
|
+
|
|
842
903
|
## Step 1: building the authorisation URL
|
|
843
904
|
|
|
844
905
|
```ruby
|
|
@@ -1044,7 +1105,7 @@ id_token, and scope. See [Tokens](tokens.md) for the full reference.
|
|
|
1044
1105
|
| Wrong `redirect_uri` (must match the one used at authorise time) | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
1045
1106
|
| PKCE verifier mismatch | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
1046
1107
|
| Wrong client credentials | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
1047
|
-
|
|
|
1108
|
+
| Timeout, or no connection to Xero | `XeroKiwi::ConnectionError`, with Faraday's error as its `cause` |
|
|
1048
1109
|
|
|
1049
1110
|
`XeroKiwi::OAuth::CodeExchangeError` inherits from `XeroKiwi::AuthenticationError`,
|
|
1050
1111
|
so you can catch the broader class if you don't care about the distinction.
|
|
@@ -1093,6 +1154,15 @@ The standalone class method **fetches JWKS fresh on every call** via
|
|
|
1093
1154
|
`Net::HTTP`. Fine for one-off use, wasteful in a hot loop. If you find
|
|
1094
1155
|
yourself calling it repeatedly, switch to the OAuth instance method.
|
|
1095
1156
|
|
|
1157
|
+
It takes the same `open_timeout:`, `timeout:` and `write_timeout:` options
|
|
1158
|
+
as `OAuth.new`, with the same defaults. Unlike the instance method, it raises
|
|
1159
|
+
`XeroKiwi::OAuth::IDTokenError` when the fetch times out, as it does for any
|
|
1160
|
+
other failure to fetch the JWKS.
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
XeroKiwi::OAuth::IDToken.verify(id_token, client_id: "...", timeout: 5)
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1096
1166
|
You can also inject your own JWKS provider:
|
|
1097
1167
|
|
|
1098
1168
|
```ruby
|
|
@@ -1224,6 +1294,7 @@ auto-refresh paths stay out of the way.
|
|
|
1224
1294
|
|-------|-----------|
|
|
1225
1295
|
| Wrong `client_id` / `client_secret` | `XeroKiwi::OAuth::ClientCredentialsError` |
|
|
1226
1296
|
| Scope the app isn't authorised for | `XeroKiwi::OAuth::ClientCredentialsError` |
|
|
1297
|
+
| Timeout, or no connection to Xero | `XeroKiwi::ConnectionError`, with Faraday's error as its `cause` |
|
|
1227
1298
|
|
|
1228
1299
|
`ClientCredentialsError` subclasses `XeroKiwi::AuthenticationError`, so an
|
|
1229
1300
|
existing `rescue XeroKiwi::AuthenticationError` still catches it. There is
|
|
@@ -1538,6 +1609,34 @@ else
|
|
|
1538
1609
|
end
|
|
1539
1610
|
```
|
|
1540
1611
|
|
|
1612
|
+
### Timeouts
|
|
1613
|
+
|
|
1614
|
+
A refresh goes to identity.xero.com, not api.xero.com, so it uses the
|
|
1615
|
+
identity timeouts: 5 seconds to connect, 10 to read and 10 to write. The
|
|
1616
|
+
client's own `timeout:` does not apply to it. When identity.xero.com does not
|
|
1617
|
+
answer in time, the refresh raises `XeroKiwi::ConnectionError` rather than
|
|
1618
|
+
holding the thread for Net::HTTP's default of 60 seconds.
|
|
1619
|
+
|
|
1620
|
+
`XeroKiwi::TokenRefresher` takes the same options as `XeroKiwi::OAuth.new`
|
|
1621
|
+
if you use it directly:
|
|
1622
|
+
|
|
1623
|
+
```ruby
|
|
1624
|
+
refresher = XeroKiwi::TokenRefresher.new(
|
|
1625
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
1626
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
|
|
1627
|
+
open_timeout: 2,
|
|
1628
|
+
timeout: 5
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
refresher.refresh(refresh_token: stored_refresh_token)
|
|
1632
|
+
```
|
|
1633
|
+
|
|
1634
|
+
| Option | Default | Purpose |
|
|
1635
|
+
|--------|---------|---------|
|
|
1636
|
+
| `open_timeout:` | `5` | Seconds to wait for a connection. |
|
|
1637
|
+
| `timeout:` | `10` | Seconds to wait for each read of the response. |
|
|
1638
|
+
| `write_timeout:` | `10` | Seconds to wait for each write of the request. |
|
|
1639
|
+
|
|
1541
1640
|
## The `on_token_refresh` callback
|
|
1542
1641
|
|
|
1543
1642
|
This is the most important integration point in the gem. Every time the
|
|
@@ -1668,7 +1767,9 @@ This:
|
|
|
1668
1767
|
current refresh token.
|
|
1669
1768
|
2. Returns `true` on success.
|
|
1670
1769
|
3. Raises `XeroKiwi::TokenRefreshError` if the client has no refresh capability,
|
|
1671
|
-
|
|
1770
|
+
`XeroKiwi::AuthenticationError` if Xero rejects the revoke, or
|
|
1771
|
+
`XeroKiwi::ConnectionError` if identity.xero.com does not answer within the
|
|
1772
|
+
[identity timeouts](#timeouts).
|
|
1672
1773
|
|
|
1673
1774
|
After revocation, **treat the client as dead.** Subsequent API calls will
|
|
1674
1775
|
401, and reactive refresh will fail because the refresh token is gone too.
|
|
@@ -2158,14 +2259,18 @@ exception, no special flag. An empty page after `modified_since:` is
|
|
|
2158
2259
|
indistinguishable from a filter that matched nothing (intentional — the
|
|
2159
2260
|
caller can treat them identically).
|
|
2160
2261
|
|
|
2161
|
-
## `include_archived:` — archived contacts
|
|
2262
|
+
## `include_archived:` — archived contacts and tracking categories
|
|
2162
2263
|
|
|
2163
|
-
Contacts only. Maps to Xero's `includeArchived`
|
|
2164
|
-
archived
|
|
2264
|
+
Contacts and tracking categories only. Maps to Xero's `includeArchived`
|
|
2265
|
+
query param, which returns archived records **alongside** active ones in
|
|
2266
|
+
the same pass.
|
|
2165
2267
|
|
|
2166
2268
|
```ruby
|
|
2167
2269
|
client.contacts(tenant, include_archived: true)
|
|
2168
2270
|
client.each_contact(tenant, include_archived: true) { |contact| … }
|
|
2271
|
+
|
|
2272
|
+
client.tracking_categories(tenant, include_archived: true)
|
|
2273
|
+
client.each_tracking_category(tenant, include_archived: true) { |category| … }
|
|
2169
2274
|
```
|
|
2170
2275
|
|
|
2171
2276
|
This is not the same as filtering on `contact_status`. A
|
|
@@ -2177,6 +2282,11 @@ It matters beyond contacts themselves: Xero keeps serving archived
|
|
|
2177
2282
|
contacts as members of contact groups, so without this you can't tell
|
|
2178
2283
|
which group members are archived locally.
|
|
2179
2284
|
|
|
2285
|
+
For tracking categories it reaches the options too. Without it, Xero leaves
|
|
2286
|
+
out archived categories *and* the archived options under active ones. Line
|
|
2287
|
+
items keep pointing at an archived option, so a sync that mirrors the list
|
|
2288
|
+
without this flag deletes options its invoices still refer to.
|
|
2289
|
+
|
|
2180
2290
|
## Combining everything
|
|
2181
2291
|
|
|
2182
2292
|
Mix and match freely:
|
|
@@ -3545,8 +3655,11 @@ categories.first.options.map(&:name) # => ["Eastside", "Westside"]
|
|
|
3545
3655
|
the `Xero-Tenant-Id` header set, and returns a `XeroKiwi::Page` of
|
|
3546
3656
|
`TrackingCategory` objects.
|
|
3547
3657
|
|
|
3548
|
-
|
|
3549
|
-
|
|
3658
|
+
Archived categories, and archived options under active ones, are left out
|
|
3659
|
+
unless you pass `include_archived: true`. See [Querying](../querying.md).
|
|
3660
|
+
|
|
3661
|
+
This endpoint isn't paged — Xero returns every category in one response, and
|
|
3662
|
+
an organisation has at most two active ones. `each_tracking_category` exists so all ten list resources behave
|
|
3550
3663
|
consistently, but there's no reason to reach for it over the plain call.
|
|
3551
3664
|
|
|
3552
3665
|
## Fetching a single category
|
|
@@ -3632,10 +3745,12 @@ so it falls back to structural equality — every attribute must match.
|
|
|
3632
3745
|
### Building a lookup from option ID to names
|
|
3633
3746
|
|
|
3634
3747
|
Line items carry only IDs and denormalised names; if you need the canonical
|
|
3635
|
-
option list, fetch the definitions once per sync and index them.
|
|
3748
|
+
option list, fetch the definitions once per sync and index them. Include
|
|
3749
|
+
archived ones, or a line item tagged with an archived option raises
|
|
3750
|
+
`KeyError`.
|
|
3636
3751
|
|
|
3637
3752
|
```ruby
|
|
3638
|
-
options = client.tracking_categories(tenant_id).flat_map do |category|
|
|
3753
|
+
options = client.tracking_categories(tenant_id, include_archived: true).flat_map do |category|
|
|
3639
3754
|
category.options.map { |option| [option.tracking_option_id, [category.name, option.name]] }
|
|
3640
3755
|
end.to_h
|
|
3641
3756
|
|
|
@@ -3649,8 +3764,13 @@ end
|
|
|
3649
3764
|
|
|
3650
3765
|
### Ignoring archived categories
|
|
3651
3766
|
|
|
3767
|
+
Xero leaves archived categories out unless you ask for them, so a plain
|
|
3768
|
+
`client.tracking_categories(tenant_id)` already returns only active ones.
|
|
3769
|
+
Filter when you fetched archived ones too, for a lookup like the one above:
|
|
3770
|
+
|
|
3652
3771
|
```ruby
|
|
3653
|
-
|
|
3772
|
+
categories = client.tracking_categories(tenant_id, include_archived: true)
|
|
3773
|
+
active = categories.select(&:active?)
|
|
3654
3774
|
```
|
|
3655
3775
|
|
|
3656
3776
|
|
|
@@ -3890,6 +4010,7 @@ StandardError
|
|
|
3890
4010
|
│ ├─ XeroKiwi::ClientError (other 4xx)
|
|
3891
4011
|
│ ├─ XeroKiwi::ServerError (5xx)
|
|
3892
4012
|
│ └─ XeroKiwi::RateLimitError (429, with retry_after + problem)
|
|
4013
|
+
├─ XeroKiwi::ConnectionError (timeout or failed connection)
|
|
3893
4014
|
├─ XeroKiwi::OAuth::StateMismatchError (CSRF check failed)
|
|
3894
4015
|
└─ XeroKiwi::OAuth::IDTokenError (ID token JWT verification failed)
|
|
3895
4016
|
```
|
|
@@ -3897,12 +4018,16 @@ StandardError
|
|
|
3897
4018
|
A few things worth noticing about the shape:
|
|
3898
4019
|
|
|
3899
4020
|
- **Everything Xero Kiwi raises** descends from `XeroKiwi::Error`. If you only want
|
|
3900
|
-
one rescue clause for "the Xero integration broke," catch this.
|
|
4021
|
+
one rescue clause for "the Xero integration broke," catch this. The one
|
|
4022
|
+
exception is a response that says it is JSON but does not parse, which
|
|
4023
|
+
raises Faraday's own `Faraday::ParsingError`.
|
|
3901
4024
|
- **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
|
|
3902
4025
|
and `body` attributes you can inspect.
|
|
3903
4026
|
- **OAuth-specific errors** for state mismatch and ID token verification
|
|
3904
4027
|
descend from `XeroKiwi::Error` directly (not `APIError`) because they don't
|
|
3905
4028
|
come from an HTTP response — they're local validation failures.
|
|
4029
|
+
- **`XeroKiwi::ConnectionError`** descends from `XeroKiwi::Error` directly
|
|
4030
|
+
too. No response arrived, so there is no status or body to carry.
|
|
3906
4031
|
- **`XeroKiwi::TokenRefreshError`** and **`XeroKiwi::OAuth::CodeExchangeError`** are
|
|
3907
4032
|
both `AuthenticationError` subclasses, because the practical recovery is
|
|
3908
4033
|
the same as for any 401: the user needs to re-authorise.
|
|
@@ -4010,6 +4135,30 @@ Extra attributes beyond `APIError`:
|
|
|
4010
4135
|
Sidekiq job can re-enqueue itself with that delay rather than hammering
|
|
4011
4136
|
Xero.
|
|
4012
4137
|
|
|
4138
|
+
### `XeroKiwi::ConnectionError`
|
|
4139
|
+
|
|
4140
|
+
Raised when a request hits its `open_timeout:`, `timeout:` or
|
|
4141
|
+
`write_timeout:`, or when the connection fails (refused, reset, the host
|
|
4142
|
+
does not resolve, or the TLS handshake fails). `XeroKiwi::Client` raises it
|
|
4143
|
+
only after its retries are exhausted, except for a TLS failure, which it
|
|
4144
|
+
does not retry. Identity calls (code exchange, client credentials, refresh,
|
|
4145
|
+
revoke and the JWKS fetch) are not retried.
|
|
4146
|
+
|
|
4147
|
+
The original `Faraday::TimeoutError`, `Faraday::ConnectionFailed` or
|
|
4148
|
+
`Faraday::SSLError` is the error's `cause`:
|
|
4149
|
+
|
|
4150
|
+
```ruby
|
|
4151
|
+
rescue XeroKiwi::ConnectionError => e
|
|
4152
|
+
e.cause # => #<Faraday::TimeoutError: Net::ReadTimeout ...>
|
|
4153
|
+
```
|
|
4154
|
+
|
|
4155
|
+
`XeroKiwi::OAuth::IDToken.verify` without `jwks:` is the one exception: it
|
|
4156
|
+
raises `XeroKiwi::OAuth::IDTokenError` for any failure to fetch the JWKS,
|
|
4157
|
+
timeouts included.
|
|
4158
|
+
|
|
4159
|
+
See [Client — Timeouts](client.md#timeouts) and [OAuth — Timeouts](oauth.md#timeouts)
|
|
4160
|
+
for the limits and their defaults.
|
|
4161
|
+
|
|
4013
4162
|
### `XeroKiwi::OAuth::StateMismatchError`
|
|
4014
4163
|
|
|
4015
4164
|
Raised by `XeroKiwi::OAuth.verify_state!` when the `state` parameter Xero
|
|
@@ -4030,7 +4179,9 @@ the ID token JWT can't be verified. Causes:
|
|
|
4030
4179
|
- Wrong audience.
|
|
4031
4180
|
- Token expired.
|
|
4032
4181
|
- Nonce mismatch (when nonce verification was requested).
|
|
4033
|
-
- Network failure fetching JWKS.
|
|
4182
|
+
- Network failure fetching JWKS, through `IDToken.verify` without `jwks:`.
|
|
4183
|
+
Through `oauth.verify_id_token`, a network failure raises
|
|
4184
|
+
`XeroKiwi::ConnectionError` instead.
|
|
4034
4185
|
|
|
4035
4186
|
The error message has a brief description of which check failed (e.g.
|
|
4036
4187
|
`"ID token verification failed: Signature verification failed"`,
|
|
@@ -4097,10 +4248,12 @@ rescue XeroKiwi::OAuth::CodeExchangeError
|
|
|
4097
4248
|
redirect_to root_path, alert: "Could not complete Xero authorisation"
|
|
4098
4249
|
rescue XeroKiwi::OAuth::IDTokenError
|
|
4099
4250
|
redirect_to root_path, alert: "Could not verify Xero identity"
|
|
4251
|
+
rescue XeroKiwi::ConnectionError
|
|
4252
|
+
redirect_to root_path, alert: "Xero did not respond. Please try again."
|
|
4100
4253
|
end
|
|
4101
4254
|
```
|
|
4102
4255
|
|
|
4103
|
-
|
|
4256
|
+
Four separate rescue clauses give you four different user-facing
|
|
4104
4257
|
messages, which is usually what you want for an OAuth callback — different
|
|
4105
4258
|
failures imply different user actions.
|
|
4106
4259
|
|
|
@@ -4326,10 +4479,12 @@ code, catch `XeroKiwi::ServerError` and handle it explicitly.
|
|
|
4326
4479
|
|----------|-----------------|
|
|
4327
4480
|
| All retries returned 429 | `XeroKiwi::RateLimitError` (with `retry_after` and `problem` attributes) |
|
|
4328
4481
|
| All retries returned 502/503/504 | `XeroKiwi::ServerError` |
|
|
4329
|
-
| All retries failed at the transport level |
|
|
4482
|
+
| All retries failed at the transport level | `XeroKiwi::ConnectionError`, with the last `Faraday::ConnectionFailed` / `Faraday::TimeoutError` as its `cause` |
|
|
4330
4483
|
|
|
4331
4484
|
The retried request count includes the original attempt, so `max: 4` means
|
|
4332
|
-
**up to 5 total HTTP requests** before giving up.
|
|
4485
|
+
**up to 5 total HTTP requests** before giving up. Each of them can wait the
|
|
4486
|
+
full read timeout, so a hung endpoint takes up to 5 times as long to fail.
|
|
4487
|
+
See [Client — Timeouts](client.md#timeouts).
|
|
4333
4488
|
|
|
4334
4489
|
## Customising the retry policy
|
|
4335
4490
|
|