jbr 3.7.1 → 3.8.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: 28dde29d20aa9063e6e24e2226d81bf69ec7593ba99efd8a6f335d9fd83c8a10
4
- data.tar.gz: 106b0db3001c4aa10c6ef449d4204c6ad2e4574494b6033e7eadf1d13f3ccc59
3
+ metadata.gz: de01eb560b5d6366142d35901817eae421a30ccebf31aeede4cacb2e97720cd3
4
+ data.tar.gz: 1a613711f19080da2368d9b2d38d2e9fff372dbd4e37126b2bc06e9d4c9a9493
5
5
  SHA512:
6
- metadata.gz: 49a59576a7951bc00a29f4ec861576be2e28e448e53c1be2911dd094ebea9a7715a5e98eb34a2d208538ee63a8bd83b402f71b461e390d38aeb7a8885b816bd1
7
- data.tar.gz: d20c5ee0c9ddb0927649b884c45f84b7d7e97e315c54a2fd29c779a1b51c3f3691b5417d79e2dc4297d86f4795966703eb3462794ad0f867a0392cc9e9e4411d
6
+ metadata.gz: 9b8fc6971e3cbe48eecf5025cb7f5b9beb9e7490a0b7a6830b0a22eb9b4c98fbe3c066686866dff576de01bfc0e10f16050c6bd1002b929bd18940dfeeccd137
7
+ data.tar.gz: 2cbdcb7fd7bd995bf7450784afdb08d59acce2c0ea788f3192a1c62aabaf00251497bfb8ff6efa57a932d96a15735dbaa9bdc4e846824679521056e7a3129e33
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [3.8.0] - 2026-08-18
2
+
3
+ - [New] `Jbr::Retriable`, a `Jbr::Error` for a query Jobber refused over what it costs rather
4
+ than over anything about the query. Every refusal read alike before, so an app had nothing to
5
+ branch on: the one worth asking again a moment later looked exactly like the one that will
6
+ say the same thing forever. It carries what Jobber reported it with — `cost`, `available`,
7
+ `maximum` and `restore_rate` — so a caller can tell a bucket that needed a second from a
8
+ query too big to ever fit in it
9
+
10
+ ## [3.7.2] - 2026-08-17
11
+
12
+ - [Change] Jobber's token endpoint answers in prose, so that is all this reads. 3.7.1 kept a
13
+ branch for the OAuth 2 `invalid_grant` in a JSON body, guarded by a rescue for a body that
14
+ would not parse — and every failure body Jobber has ever been seen to send is prose, so the
15
+ rescue was the path and the branch it protected had never once been taken. Both are gone
16
+
1
17
  ## [3.7.1] - 2026-08-17
2
18
 
3
19
  - [Fix] A refresh token Jobber will not take gives the credentials up, as it always should
data/README.md CHANGED
@@ -67,11 +67,11 @@ Revoke credentials:
67
67
  oauth.delete
68
68
  ```
69
69
 
70
- Credentials go bad only when Jobber says so of the grant itself: the `invalid_grant` it names,
71
- or the 401 it answers a refresh token it will not take. Either sets `invalid_at` and answers
72
- queries with nothing. Anything else that goes wrong raises `Jbr::Error` instead a 500, a rate
73
- limit, and the 401 Jobber answers an app whose own client id and secret are wrong, which is
74
- every account's grant at once rather than this one's:
70
+ Credentials go bad only when Jobber says so of the grant itself `The provided refresh token
71
+ is not valid.` which sets `invalid_at` and answers queries with nothing. Anything else that
72
+ goes wrong raises `Jbr::Error` instead: a 500, a rate limit, and the 401 Jobber answers an app
73
+ whose own client id and secret are wrong, which is every account's grant at once rather than
74
+ this one's:
75
75
 
76
76
  ```ruby
77
77
  oauth.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
@@ -228,6 +228,16 @@ what happened, so the caller can decide:
228
228
  Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
229
229
  ```
230
230
 
231
+ That one is a `Jbr::Retriable`, a `Jbr::Error` for a refusal worth asking again, carrying the
232
+ numbers to decide with:
233
+
234
+ ```ruby
235
+ error.cost # => 1885, what the query was priced at
236
+ error.available # => 1254, what the bucket held when it was asked
237
+ error.maximum # => 10000, what the bucket holds when full — a cost above it never fits
238
+ error.restore_rate # => 500, points a second
239
+ ```
240
+
231
241
  That arrives as a `Jbr::Error`. 631 points short of a query the bucket holds five times over,
232
242
  which a second would have refilled — worth asking again. A cost above `maximumAvailable` is
233
243
  worth nothing but a smaller query. Either way the decision belongs to whoever called: from a
@@ -22,12 +22,23 @@ module GraphQL
22
22
  raise Unauthorized, response.body if response.code == '401'
23
23
  raise Error, response.body unless response.is_a? Net::HTTPSuccess
24
24
  body = JSON.parse(response.body)
25
- raise Error, refusal(body) if body['errors'].present?
25
+ raise refusal_for(body) if body['errors'].present?
26
26
  body.fetch('data')
27
27
  end
28
28
 
29
29
  private
30
30
 
31
+ # Refused over cost where the endpoint names the code for it, or prices the query above
32
+ # what it says was left. Anything else is a refusal of the query itself.
33
+ def refusal_for(body)
34
+ cost = body['extensions'].to_h['cost'].to_h
35
+ available = cost['throttleStatus'].to_h['currentlyAvailable']
36
+ coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
37
+ priced = available && cost['requestedQueryCost'].to_f > available.to_f
38
+
39
+ coded || priced ? Throttled.new(refusal(body), cost) : Error.new(refusal(body))
40
+ end
41
+
31
42
  # What the endpoint refused, and — where it priced the refusal — what the query would have
32
43
  # cost against what was available. `Throttled` on its own leaves a caller unable to tell a
33
44
  # query too big to ever run from a bucket that a moment would have refilled.
@@ -0,0 +1,16 @@
1
+ module GraphQL
2
+ # An endpoint refusing a query for what it costs rather than for anything about the query
3
+ # itself. Worth telling apart: the same query is answered once the budget it is priced
4
+ # against has refilled.
5
+ class Throttled < Error
6
+ # @param message [String] what the endpoint said, with the numbers it said it with.
7
+ # @param cost [Hash] what it priced the query at, and the budget it priced it against.
8
+ def initialize(message, cost = {})
9
+ super message
10
+ @cost = cost
11
+ end
12
+
13
+ # @return [Hash] those numbers, in the endpoint's own words.
14
+ attr_reader :cost
15
+ end
16
+ end
data/lib/jbr/oauth.rb CHANGED
@@ -47,6 +47,8 @@ module Jbr
47
47
  client.query statement, variables: variables
48
48
  rescue GraphQL::Unauthorized
49
49
  refresh ? retry : {}
50
+ rescue GraphQL::Throttled => error
51
+ raise Retriable.new(error.message, error.cost)
50
52
  rescue GraphQL::Error => error
51
53
  # The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
52
54
  # was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
@@ -0,0 +1,20 @@
1
+ module Jbr
2
+ # Jobber refusing a query over what it costs rather than over anything about the query. The
3
+ # bucket it is priced against refills, so the same question asked later is answered.
4
+ class Retriable < Error
5
+ # @param message [String] what Jobber said, with the numbers it said it with.
6
+ # @param cost [Hash] the `requestedQueryCost` and the `throttleStatus` beside it.
7
+ def initialize(message, cost = {})
8
+ super message
9
+ status = cost['throttleStatus'].to_h
10
+ @cost = cost['requestedQueryCost']
11
+ @available = status['currentlyAvailable']
12
+ @maximum = status['maximumAvailable']
13
+ @restore_rate = status['restoreRate']
14
+ end
15
+
16
+ # What the query would have cost, the budget it was priced against, and how fast that
17
+ # budget refills. A cost above the maximum is one no waiting will pay for.
18
+ attr_reader :cost, :available, :maximum, :restore_rate
19
+ end
20
+ end
data/lib/jbr/token.rb CHANGED
@@ -4,12 +4,10 @@ module Jbr
4
4
  # Where a grant is exchanged.
5
5
  URL = 'https://api.getjobber.com/api/oauth/token'
6
6
 
7
- # What Jobber calls a grant that is no good, in the OAuth 2 word for it.
8
- REFUSAL = 'invalid_grant'
9
-
10
- # And what it calls one in the prose it answers a refresh token with instead. Specific to
11
- # the token rather than to the app, which is the whole reason for reading the body.
12
- REFUSAL_TEXT = /refresh token is not valid/
7
+ # What Jobber says of a grant it will not take, in the prose it answers with. About the
8
+ # token rather than about the app, which is what tells it from the 401 a wrong client id and
9
+ # secret get — and that one is every account at once rather than this one.
10
+ REFUSAL = /refresh token is not valid/
13
11
 
14
12
  # Trade a grant for credentials.
15
13
  # @param params [Hash] the grant, and the app making the exchange.
@@ -18,7 +16,7 @@ module Jbr
18
16
  # @return [Hash] the tokens, and the moment the access one expires.
19
17
  def self.post(params = {})
20
18
  response = Net::HTTP.post_form URI(URL), params
21
- raise Refused, response.body if refused? response
19
+ raise Refused, response.body if REFUSAL.match? response.body
22
20
  raise Error, response.body unless response.is_a? Net::HTTPSuccess
23
21
 
24
22
  output = JSON.parse response.body
@@ -26,22 +24,5 @@ module Jbr
26
24
  expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
27
25
  }
28
26
  end
29
-
30
- # Refused where Jobber turns the grant down rather than failing to answer about it: it names
31
- # the word, or it names the token. Never the status on its own — Jobber answers 401 to a
32
- # refresh token it will not take and 401 to an app whose own credentials are wrong, and
33
- # acting on the second as though it were the first would disconnect every account at once.
34
- def self.refused?(response)
35
- return false if response.is_a? Net::HTTPSuccess
36
-
37
- coded_refusal?(response.body) || REFUSAL_TEXT.match?(response.body.to_s)
38
- end
39
-
40
- def self.coded_refusal?(body)
41
- JSON.parse(body)['error'] == REFUSAL
42
- rescue JSON::ParserError
43
- false
44
- end
45
- private_class_method :refused?, :coded_refusal?
46
27
  end
47
28
  end
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '3.7.1'
4
+ VERSION = '3.8.0'
5
5
  end
data/lib/jbr.rb CHANGED
@@ -11,6 +11,7 @@ require 'active_support/core_ext/array/conversions'
11
11
 
12
12
  require 'graphql/error'
13
13
  require 'graphql/unauthorized'
14
+ require 'graphql/throttled'
14
15
  require 'graphql/client'
15
16
 
16
17
  require 'jbr/mock'
@@ -18,6 +19,7 @@ require 'jbr/mock'
18
19
  require 'jbr/url'
19
20
  require 'jbr/error'
20
21
  require 'jbr/refused'
22
+ require 'jbr/retriable'
21
23
  require 'jbr/token'
22
24
  require 'jbr/refreshing'
23
25
  # Phone before Cliental, and Cliental before the records that include it: what each asks
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jbr
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.1
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -106,6 +106,7 @@ files:
106
106
  - README.md
107
107
  - lib/graphql/client.rb
108
108
  - lib/graphql/error.rb
109
+ - lib/graphql/throttled.rb
109
110
  - lib/graphql/unauthorized.rb
110
111
  - lib/jbr.rb
111
112
  - lib/jbr/account.rb
@@ -144,6 +145,7 @@ files:
144
145
  - lib/jbr/refused.rb
145
146
  - lib/jbr/request.rb
146
147
  - lib/jbr/resource.rb
148
+ - lib/jbr/retriable.rb
147
149
  - lib/jbr/token.rb
148
150
  - lib/jbr/url.rb
149
151
  - lib/jbr/version.rb