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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 122c7e012d619aabdf473185bf0e39e1e04204940d2c6d6b96888d1b6f73b76c
4
- data.tar.gz: 2bbb94621f3e417275b1ea326a2e54e50f03083c01991863e2f91a16af5945ea
3
+ metadata.gz: 5aeae0acf1024528b094ffc690323179bd336c9817bef6a897258b5304f66844
4
+ data.tar.gz: 2ab0071a933a39e93a10ef7ed7658b354e2043cc753441c65491443fff5e22a2
5
5
  SHA512:
6
- metadata.gz: 0266fde7e2c101e0533f9e3202ba2871f0a17c170e27ff82dd380448028f608da61f697c3ca08d2a965245125c0987f0a9ddb54ccc01e0a89f47d437fd5aa62d
7
- data.tar.gz: 93e24cd65f1ebf4f7dec4a97fe17d352fb7f6d2ed95f22e6466f403f9997859d16d51569614736c9a1ae77351f94b306dd8304b8f368f465d7c040f2eebb4514
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
- This endpoint isn't paged — an organisation has at most two active
56
- categories. `each_tracking_category` exists so all ten list resources behave
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
- active = client.tracking_categories(tenant_id).select(&:active?)
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
- Three separate rescue clauses give you three different user-facing
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
- | Network error | bubbles up as a `Faraday::ConnectionFailed` |
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` query param, which returns
239
- archived contacts **alongside** active ones in the same pass.
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 | The underlying `Faraday::ConnectionFailed` / `Faraday::TimeoutError` |
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
- or `XeroKiwi::AuthenticationError` if Xero rejects the revoke.
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.
@@ -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 into
881
- # a XeroKiwi exception, *after* retries have been exhausted.
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 onto our exception
984
- # hierarchy. Lives outside the retry middleware so it only fires on the
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
@@ -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
@@ -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 middleware
24
- # — token endpoints aren't subject to the same rate limits as the API,
25
- # and retrying a failed token call usually masks a real configuration
26
- # problem instead of fixing a transient one.
27
- def build_http(adapter: nil)
28
- Faraday.new(url: URL) do |f|
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(id_token, client_id:, nonce: nil, jwks: nil)
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
- decoded = decode(id_token, client_id, jwks)
42
- claims = decoded.first
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, jwks)
50
- jwks_proc = jwks || method(:fetch_jwks_directly)
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
- body = Net::HTTP.get(URI(Identity::JWKS_URL))
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}"
@@ -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(client_id:, client_secret:, redirect_uri: nil, adapter: nil)
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(client_id:, client_secret:, adapter: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
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
- | Network error | bubbles up as a `Faraday::ConnectionFailed` |
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
- or `XeroKiwi::AuthenticationError` if Xero rejects the revoke.
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` query param, which returns
2164
- archived contacts **alongside** active ones in the same pass.
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
- This endpoint isn't paged — an organisation has at most two active
3549
- categories. `each_tracking_category` exists so all ten list resources behave
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
- active = client.tracking_categories(tenant_id).select(&:active?)
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
- Three separate rescue clauses give you three different user-facing
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 | The underlying `Faraday::ConnectionFailed` / `Faraday::TimeoutError` |
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xero-kiwi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Douglas Greyling