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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e52bfcc3939a00a86a7bc26b931aa6e7553b2479fb3b2b36c3215daccad754de
4
- data.tar.gz: 0c13e42901c11b857c0476dea1c898a46a12404de66231df0b82cb9c899d9a71
3
+ metadata.gz: 5aeae0acf1024528b094ffc690323179bd336c9817bef6a897258b5304f66844
4
+ data.tar.gz: 2ab0071a933a39e93a10ef7ed7658b354e2043cc753441c65491443fff5e22a2
5
5
  SHA512:
6
- metadata.gz: 1d9b16a8564e87c0935dfd301ec480fa856c7374f70ca939eef877d4e108f8e54a9ef38a1454451d848de1d6c697addb561f3bf81e3405414dbb0567c9ff77d2
7
- data.tar.gz: 169f97330de612d1c940bbd0ee0d671c5d5ae7cc4284f5cc42af9b5d59cd37c2e5a68a81113d7e56a66c6e03a5a649bac779783d9b055db143f04684cd6d954c
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
- 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
@@ -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
@@ -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 into
886
- # 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.
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 onto our exception
989
- # hierarchy. Lives outside the retry middleware so it only fires on the
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
@@ -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.11.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.
@@ -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
- Three separate rescue clauses give you three different user-facing
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 | 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` |
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
 
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Douglas Greyling