xero-kiwi 0.11.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 +31 -0
- data/docs/client.md +32 -0
- data/docs/errors.md +36 -3
- data/docs/oauth.md +40 -1
- data/docs/retries-and-rate-limits.md +4 -2
- data/docs/tokens.md +31 -1
- data/lib/xero_kiwi/client.rb +23 -7
- 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 +143 -7
- 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,36 @@
|
|
|
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
|
+
|
|
3
34
|
## [0.11.0] - 2026-10-08
|
|
4
35
|
|
|
5
36
|
### Added
|
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
|
|
@@ -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
|
|
@@ -882,8 +890,9 @@ module XeroKiwi
|
|
|
882
890
|
# Middleware order matters. Outbound runs top-to-bottom; inbound runs in
|
|
883
891
|
# reverse. We want:
|
|
884
892
|
#
|
|
885
|
-
# 1. ResponseHandler (outermost) — converts the FINAL response status
|
|
886
|
-
# 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.
|
|
887
896
|
# 2. Retry — retries on 429/503 (respecting Retry-After) and on transport
|
|
888
897
|
# exceptions.
|
|
889
898
|
# 3. RateLimitCapture — records Xero's quota headers. Below Retry so
|
|
@@ -899,7 +908,7 @@ module XeroKiwi
|
|
|
899
908
|
# gets retried by Faraday before we ever raise RateLimitError, and the
|
|
900
909
|
# exception only fires once we've truly given up.
|
|
901
910
|
def build_http
|
|
902
|
-
Faraday.new(url: BASE_URL) do |f|
|
|
911
|
+
Faraday.new(url: BASE_URL, request: @timeouts) do |f|
|
|
903
912
|
f.use ResponseHandler
|
|
904
913
|
f.request :retry, @retry_options
|
|
905
914
|
f.use RateLimitCapture, @rate_limits
|
|
@@ -985,10 +994,17 @@ module XeroKiwi
|
|
|
985
994
|
end
|
|
986
995
|
end
|
|
987
996
|
|
|
988
|
-
# Faraday middleware that maps non-2xx responses
|
|
989
|
-
# hierarchy. Lives outside the retry middleware so it
|
|
990
|
-
# 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.
|
|
991
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
|
+
|
|
992
1008
|
def on_complete(env)
|
|
993
1009
|
# 304 Not Modified is a valid response to a conditional GET
|
|
994
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.
|
|
@@ -3909,6 +4010,7 @@ StandardError
|
|
|
3909
4010
|
│ ├─ XeroKiwi::ClientError (other 4xx)
|
|
3910
4011
|
│ ├─ XeroKiwi::ServerError (5xx)
|
|
3911
4012
|
│ └─ XeroKiwi::RateLimitError (429, with retry_after + problem)
|
|
4013
|
+
├─ XeroKiwi::ConnectionError (timeout or failed connection)
|
|
3912
4014
|
├─ XeroKiwi::OAuth::StateMismatchError (CSRF check failed)
|
|
3913
4015
|
└─ XeroKiwi::OAuth::IDTokenError (ID token JWT verification failed)
|
|
3914
4016
|
```
|
|
@@ -3916,12 +4018,16 @@ StandardError
|
|
|
3916
4018
|
A few things worth noticing about the shape:
|
|
3917
4019
|
|
|
3918
4020
|
- **Everything Xero Kiwi raises** descends from `XeroKiwi::Error`. If you only want
|
|
3919
|
-
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`.
|
|
3920
4024
|
- **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
|
|
3921
4025
|
and `body` attributes you can inspect.
|
|
3922
4026
|
- **OAuth-specific errors** for state mismatch and ID token verification
|
|
3923
4027
|
descend from `XeroKiwi::Error` directly (not `APIError`) because they don't
|
|
3924
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.
|
|
3925
4031
|
- **`XeroKiwi::TokenRefreshError`** and **`XeroKiwi::OAuth::CodeExchangeError`** are
|
|
3926
4032
|
both `AuthenticationError` subclasses, because the practical recovery is
|
|
3927
4033
|
the same as for any 401: the user needs to re-authorise.
|
|
@@ -4029,6 +4135,30 @@ Extra attributes beyond `APIError`:
|
|
|
4029
4135
|
Sidekiq job can re-enqueue itself with that delay rather than hammering
|
|
4030
4136
|
Xero.
|
|
4031
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
|
+
|
|
4032
4162
|
### `XeroKiwi::OAuth::StateMismatchError`
|
|
4033
4163
|
|
|
4034
4164
|
Raised by `XeroKiwi::OAuth.verify_state!` when the `state` parameter Xero
|
|
@@ -4049,7 +4179,9 @@ the ID token JWT can't be verified. Causes:
|
|
|
4049
4179
|
- Wrong audience.
|
|
4050
4180
|
- Token expired.
|
|
4051
4181
|
- Nonce mismatch (when nonce verification was requested).
|
|
4052
|
-
- 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.
|
|
4053
4185
|
|
|
4054
4186
|
The error message has a brief description of which check failed (e.g.
|
|
4055
4187
|
`"ID token verification failed: Signature verification failed"`,
|
|
@@ -4116,10 +4248,12 @@ rescue XeroKiwi::OAuth::CodeExchangeError
|
|
|
4116
4248
|
redirect_to root_path, alert: "Could not complete Xero authorisation"
|
|
4117
4249
|
rescue XeroKiwi::OAuth::IDTokenError
|
|
4118
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."
|
|
4119
4253
|
end
|
|
4120
4254
|
```
|
|
4121
4255
|
|
|
4122
|
-
|
|
4256
|
+
Four separate rescue clauses give you four different user-facing
|
|
4123
4257
|
messages, which is usually what you want for an OAuth callback — different
|
|
4124
4258
|
failures imply different user actions.
|
|
4125
4259
|
|
|
@@ -4345,10 +4479,12 @@ code, catch `XeroKiwi::ServerError` and handle it explicitly.
|
|
|
4345
4479
|
|----------|-----------------|
|
|
4346
4480
|
| All retries returned 429 | `XeroKiwi::RateLimitError` (with `retry_after` and `problem` attributes) |
|
|
4347
4481
|
| All retries returned 502/503/504 | `XeroKiwi::ServerError` |
|
|
4348
|
-
| 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` |
|
|
4349
4483
|
|
|
4350
4484
|
The retried request count includes the original attempt, so `max: 4` means
|
|
4351
|
-
**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).
|
|
4352
4488
|
|
|
4353
4489
|
## Customising the retry policy
|
|
4354
4490
|
|