jbr 3.5.1 → 3.6.1

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: f5c1536ff754171e5bafc8e5501088b0372c673c98e3891786b43da0a69f5c75
4
- data.tar.gz: 61bf56b9de47ec4ada6e406967c36240a95351d50a007730ce2506c0d7244864
3
+ metadata.gz: b8ab569aab529193190b37620954eb2e95db5ac39ccb2a085a7caa2b6cf66981
4
+ data.tar.gz: 4d331b9c225a2d0fb8a978f166985f416528a23eac283c4afb31924aecf7bc8b
5
5
  SHA512:
6
- metadata.gz: 52d0070f75aa56d58874e301c0bb11b8b78fb108ac2327617094f9ee3676302fb8b59dee89942203dc993305356a43165b0fb053ac0257f1cb0f1299949ad5b3
7
- data.tar.gz: 73c45d7ed55f796c7e0068eb19c33bc07e0a1ac2359a594b12eca096b43e8bf86058e4f67b3f03877d02e337a87ee81be3efca500df6deb6b9470a175065bdbc
6
+ metadata.gz: fbe6e4b68d03262584a83279ca00eff9a4a5886413498d5db6cbec643e070d12a863188914e95c87a9365fe8f7aa419796c4238824d3346847fdbbd71960c74e
7
+ data.tar.gz: c0a9624a47f0c4f3706acdabf95bab82cb9d6e2deef035e88edb08daa89d4cdb686bf6489ea6e3c5fcd5b67dcc2cae4388ed19d4f6db036144366a6bcf995864
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [3.6.1] - 2026-08-17
2
+
3
+ - [Fix] 3.6.0 shipped without the two files it added, `graphql/throttled` and `jbr/asking`, so
4
+ requiring the gem raised `LoadError` and nothing worked at all. The gem's file list comes
5
+ from `git ls-files` and the release commit never added them. 3.6.0 is yanked
6
+
7
+ ## [3.6.0] - 2026-08-17
8
+
9
+ - [Fix] A refusal for cost the bucket can recover from is waited out and asked again, up to
10
+ four times, rather than raised. A walk of many pages drains the bucket faster than it
11
+ refills and gets `Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)` — 631
12
+ points short of a query the bucket holds five times over. The refusal prices the query, so
13
+ the throttle already knows the shortfall: 1.26 seconds at 500 a second, and the same
14
+ question is answered
15
+ - [Change] Only a query costing more than the bucket *ever* holds is given up on, since
16
+ waiting cannot help it. Where Jobber names no ceiling, one wait is tried rather than the
17
+ worst assumed
18
+ - [New] `GraphQL::Throttled`, a `GraphQL::Error` for a query refused over what it costs rather
19
+ than over anything about the query. It never leaves the gem — a caller still sees
20
+ `Jbr::Error` — but it is what tells the two refusals apart inside it
21
+ - [Change] What credentials do when they ask Jobber something is `Jbr::Asking`, mixed into
22
+ `Jbr::OAuth`, which was over a hundred lines with the retry in it
23
+
1
24
  ## [3.5.1] - 2026-08-17
2
25
 
3
26
  - [Change] A line item is how many of what, and nothing else. `description` was answered and
data/README.md CHANGED
@@ -204,11 +204,19 @@ done about either — every request waits for itself:
204
204
  A request that follows no other waits for nothing, so a single `find` is as quick as it ever
205
205
  was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
206
206
 
207
- Where Jobber refuses for cost anyway, the `Jbr::Error` raised says what the query would have
208
- cost against what was available `Throttled (cost 12400, 9500 of 10000 available, restoring
209
- 500/s)` — so a query too big to ever run reads apart from a bucket that needed a moment. Every
210
- connection this gem asks for is bounded, because Jobber prices an unbounded one at its own
211
- maximum: twenty lines to a job, and twenty jobs or visits to a page.
207
+ Where Jobber refuses anyway, the refusal says what the query would have cost against what was
208
+ available, and what happens next follows from those two numbers:
209
+
210
+ ```
211
+ Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
212
+ ```
213
+
214
+ 631 points short of a query the bucket holds five times over, so the shortfall is waited out —
215
+ 1.26 seconds at 500 a second — and the same question asked again, up to four times. Only a
216
+ query costing more than the bucket *ever* holds is given up on, since no wait would help it;
217
+ that one arrives as a `Jbr::Error` carrying the line above. Every connection this gem asks for
218
+ is bounded, to keep a query on the affordable side of that: twenty lines to a job, and twenty
219
+ jobs or visits to a page.
212
220
 
213
221
  ### Events
214
222
 
@@ -26,12 +26,24 @@ module GraphQL
26
26
  # Before the refusal, not after it: an endpoint that reports what it will still answer
27
27
  # reports it when it says no, which is when a caller most needs to know.
28
28
  yield body['extensions'] if block_given?
29
- raise Error, refusal(body) if body['errors'].present?
29
+ raise refusal_for(body), refusal(body) if body['errors'].present?
30
30
  body.fetch('data')
31
31
  end
32
32
 
33
33
  private
34
34
 
35
+ # Refused for cost, or refused for the query. An endpoint that priced the query above what
36
+ # was left says so by naming the code, and says it again in the two numbers it reports —
37
+ # either is enough, since only one of them is documented and only one has been seen.
38
+ def refusal_for(body)
39
+ cost = body['extensions'].to_h['cost'].to_h
40
+ available = cost['throttleStatus'].to_h['currentlyAvailable']
41
+ coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
42
+ priced = available && cost['requestedQueryCost'].to_f > available.to_f
43
+
44
+ coded || priced ? Throttled : Error
45
+ end
46
+
35
47
  # What the endpoint refused, and — where it priced the refusal — what the query would have
36
48
  # cost against what was available. `Throttled` on its own leaves a caller unable to tell a
37
49
  # query too big to ever run from a bucket that only needed a moment.
@@ -0,0 +1,6 @@
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 may be answered a moment later.
4
+ class Throttled < Error
5
+ end
6
+ end
data/lib/jbr/asking.rb ADDED
@@ -0,0 +1,40 @@
1
+ module Jbr
2
+ # What credentials do when they ask Jobber something: pace the request against both limits
3
+ # Jobber holds an app to, wait out a refusal it can recover from, and refresh a token that
4
+ # went stale on the way.
5
+ module Asking
6
+ # How many times a query refused for cost is asked again. Once is the answer where the
7
+ # bucket only had to refill, since the wait is worked out to cover exactly the shortfall.
8
+ # The rest are for a bucket the whole app shares: another process may drain it again while
9
+ # this one waits, and each attempt costs only the seconds Jobber says it needs.
10
+ ATTEMPTS = 4
11
+
12
+ # Run a statement, waiting for what Jobber will still answer and refreshing a stale token.
13
+ # @return [Hash] the data Jobber answered, or empty when the credentials are dead.
14
+ def query(statement, variables: {})
15
+ attempts = 0
16
+ begin
17
+ throttle.wait
18
+ client.query(statement, variables: variables) { |extensions| throttle.read extensions }
19
+ rescue GraphQL::Unauthorized
20
+ refresh ? retry : {}
21
+ rescue GraphQL::Throttled => error
22
+ # The refusal priced the query, so the throttle now knows the shortfall and `wait` at
23
+ # the top of the retry sits out exactly that. Only a query costing more than the bucket
24
+ # ever holds is hopeless; the rest is a walk that asked a moment too early.
25
+ raise Error, error.message unless throttle.affordable? && (attempts += 1) < ATTEMPTS
26
+
27
+ retry
28
+ rescue GraphQL::Error => error
29
+ # The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
30
+ # was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
31
+ # up instead of hearing that Jobber would not answer.
32
+ raise Error, error.message
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def throttle = @throttle ||= Throttle.new
39
+ end
40
+ end
data/lib/jbr/oauth.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  module Jbr
2
2
  # Credentials for one Jobber account, and the gateway to all they read or write.
3
3
  class OAuth
4
+ include Asking
5
+
4
6
  # The mutation that revokes the app on the account.
5
7
  DISCONNECT_MUTATION = <<~GRAPHQL
6
8
  mutation Disconnect {
@@ -34,20 +36,6 @@ module Jbr
34
36
  def requests = Request.new oauth: self
35
37
  def visits = Visits.new oauth: self
36
38
 
37
- # Run a statement, waiting for what Jobber will still answer and refreshing a stale token.
38
- # @return [Hash] the data Jobber answered, or empty when the credentials are dead.
39
- def query(statement, variables: {})
40
- throttle.wait
41
- client.query(statement, variables: variables) { |extensions| throttle.read extensions }
42
- rescue GraphQL::Unauthorized
43
- refresh ? retry : {}
44
- rescue GraphQL::Error => error
45
- # The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
46
- # was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
47
- # up instead of hearing that Jobber would not answer.
48
- raise Error, error.message
49
- end
50
-
51
39
  # Delete a token. If the token is invalid, do nothing.
52
40
  def delete
53
41
  client.query DISCONNECT_MUTATION
@@ -76,8 +64,6 @@ module Jbr
76
64
 
77
65
  private
78
66
 
79
- def throttle = @throttle ||= Throttle.new
80
-
81
67
  def refresh
82
68
  output = self.class.post refresh_token: @refresh_token, grant_type: 'refresh_token'
83
69
  @access_token = output[:access_token]
data/lib/jbr/throttle.rb CHANGED
@@ -25,9 +25,17 @@ module Jbr
25
25
  # and only an answered one reports an actual.
26
26
  @cost = (cost['actualQueryCost'] || cost['requestedQueryCost']).to_f
27
27
  @available = status['currentlyAvailable'].to_f
28
+ @maximum = status['maximumAvailable'].to_f
28
29
  @restore_rate = status['restoreRate'].to_f
29
30
  end
30
31
 
32
+ # Whether the bucket could ever pay for the query it last priced. A refusal is worth
33
+ # waiting out where it could: the shortfall refills and the same query goes through. Where
34
+ # the query costs more than the bucket ever holds, no wait will do — and where Jobber said
35
+ # nothing about the ceiling, one attempt at waiting is cheaper than assuming the worst.
36
+ # @return [Boolean] false only where the ceiling is known and the query is over it.
37
+ def affordable? = !@maximum.to_f.positive? || @cost.to_f <= @maximum
38
+
31
39
  private
32
40
 
33
41
  # Nothing is owed to the first caller: a request that follows no other is not too soon.
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.5.1'
4
+ VERSION = '3.6.1'
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'
@@ -26,6 +27,7 @@ require 'jbr/cliental'
26
27
  require 'jbr/named'
27
28
  require 'jbr/resource'
28
29
  require 'jbr/throttle'
30
+ require 'jbr/asking'
29
31
  require 'jbr/request'
30
32
  require 'jbr/oauth'
31
33
 
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.5.1
4
+ version: 3.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -106,9 +106,11 @@ 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
113
+ - lib/jbr/asking.rb
112
114
  - lib/jbr/client.rb
113
115
  - lib/jbr/cliental.rb
114
116
  - lib/jbr/error.rb