jbr 3.5.0 → 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: 0f0002106d5864e3b0224d1a454f67813be100c2fca6abd411417f81ac913e53
4
- data.tar.gz: 1e7998f2402eea55768b060ddc4057a7d513c29741b419fa5ade852caaf71a7a
3
+ metadata.gz: b8ab569aab529193190b37620954eb2e95db5ac39ccb2a085a7caa2b6cf66981
4
+ data.tar.gz: 4d331b9c225a2d0fb8a978f166985f416528a23eac283c4afb31924aecf7bc8b
5
5
  SHA512:
6
- metadata.gz: 6b7ce3628d2828f50173f6e41bccf9e6cb3dd2c9197dc7bcf0b30e814f49e5e27db25641835e0b8c174cd9c6e6e96daae74b16c3f532e67cf7fb6b45f466035c
7
- data.tar.gz: 1cf960b5042097b943e62dd9ced8ab0e4adf0a084f8ca6143ea48b38c9e2510fae13b21d0576c9165916af7a85d587726d6ed34875c34a32bbe019c83632f662
6
+ metadata.gz: fbe6e4b68d03262584a83279ca00eff9a4a5886413498d5db6cbec643e070d12a863188914e95c87a9365fe8f7aa419796c4238824d3346847fdbbd71960c74e
7
+ data.tar.gz: c0a9624a47f0c4f3706acdabf95bab82cb9d6e2deef035e88edb08daa89d4cdb686bf6489ea6e3c5fcd5b67dcc2cae4388ed19d4f6db036144366a6bcf995864
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
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
+
24
+ ## [3.5.1] - 2026-08-17
25
+
26
+ - [Change] A line item is how many of what, and nothing else. `description` was answered and
27
+ read out in `to_s` alongside the quantity and the name, and no caller ever wanted it — so it
28
+ is not asked of Jobber any more, which is a smaller query as well as a smaller class.
29
+ `quantified` goes with it: it existed only to be the half of `to_s` without a description,
30
+ and `to_s` is that on its own now. A job's summary is `line_items.to_sentence`, since
31
+ `to_sentence` reads each line's own string form
32
+
1
33
  ## [3.5.0] - 2026-08-17
2
34
 
3
35
  - [Fix] A walk of jobs carrying their line items was refused outright: `Throttled`. Jobber
data/README.md CHANGED
@@ -119,11 +119,9 @@ job.line_items # => an Array of the lines the job is made of
119
119
  line = job.line_items.first
120
120
  line.quantity # => 3, whole where Jobber's own Float has nothing after the point, and 3.5
121
121
  # where it has: `3 Faucets`, or `3.5 Hours` for what was really billed
122
- line.quantified # => '3 Bathroom Faucet Installation', how many of what
123
122
  line.name # => 'Bathroom Faucet Installation'
124
- line.description # => 'Professional installation of a new bathroom faucet'
125
- line.to_s # => '3 Bathroom Faucet Installation (Professional installation of a new bathroom
126
- # faucet)', and without the parenthesis where nobody described it
123
+ line.to_s # => '3 Bathroom Faucet Installation', how many of what, and the name alone where
124
+ # Jobber holds no quantity for the line
127
125
  ```
128
126
 
129
127
  Every line Jobber holds is in the list, up to twenty of them, in the order it holds them and
@@ -206,11 +204,19 @@ done about either — every request waits for itself:
206
204
  A request that follows no other waits for nothing, so a single `find` is as quick as it ever
207
205
  was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
208
206
 
209
- Where Jobber refuses for cost anyway, the `Jbr::Error` raised says what the query would have
210
- cost against what was available `Throttled (cost 12400, 9500 of 10000 available, restoring
211
- 500/s)` — so a query too big to ever run reads apart from a bucket that needed a moment. Every
212
- connection this gem asks for is bounded, because Jobber prices an unbounded one at its own
213
- 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.
214
220
 
215
221
  ### Events
216
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/job.rb CHANGED
@@ -10,11 +10,11 @@ module Jbr
10
10
  def instructions = @node['instructions']
11
11
 
12
12
  # What the job's lines add up to, each as how many of what: `3 Faucet install and 2 Valve
13
- # change`. The lines say what the work was where a title only says what it was called, so
14
- # this reads better than one and falls back to {#name} where the job has no lines, or
15
- # where the query never asked for them. Never nil and never empty.
16
- # @return [String] the lines as a sentence, or the title, or the ID.
17
- def summary = line_items.map(&:quantified).to_sentence.presence || name
13
+ # change` `to_sentence` reading each line's own string form. The lines say what the work
14
+ # was where a title only says what it was called, so this reads better than one, and falls
15
+ # back to {#name} where the job has no lines or the query never asked for them.
16
+ # @return [String] the lines as a sentence, or the title, or the ID. Never nil, never empty.
17
+ def summary = line_items.to_sentence.presence || name
18
18
 
19
19
  # @return [String, nil] where Jobber files the job in its own workflow.
20
20
  def status = @node['jobStatus']
data/lib/jbr/line_item.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  module Jbr
2
- # One line of the work a job is made of: how many of a thing, what it is called, and what
3
- # doing it involves.
2
+ # One line of the work a job is made of: how many of a thing, and what it is called.
4
3
  class LineItem < Resource
5
4
  # What Jobber calls each field of a line.
6
- FIELDS = %w[quantity name description]
5
+ FIELDS = %w[quantity name]
7
6
 
8
7
  # The most lines to read off one record. Bounded because Jobber prices a connection by the
9
8
  # page it is asked for and prices an unbounded one at its own maximum, so the lines of a
@@ -23,16 +22,9 @@ module Jbr
23
22
  # @return [String, nil] what the line is called.
24
23
  def name = @node['name']
25
24
 
26
- # @return [String, nil] what doing it involves, in whoever wrote the line's own words.
27
- def description = @node['description']
28
-
29
25
  # @return [String] how many of what: `3 Bathroom Faucet Installation`, and the name alone
30
26
  # where Jobber holds no quantity for the line.
31
- def quantified = [ quantity, name ].compact.join ' '
32
-
33
- # @return [String] how a line reads: `3 Faucet install (Fits a new faucet)`, and without
34
- # the parenthesis where nobody described it.
35
- def to_s = [ quantified, described ].compact.join ' '
27
+ def to_s = [ quantity, name ].compact.join ' '
36
28
 
37
29
  private
38
30
 
@@ -40,7 +32,5 @@ module Jbr
40
32
  # `3 Faucets` rather than `3.0 Faucets`. A fraction keeps its point — `3.5 Faucets` —
41
33
  # since rounding it would lie about what was billed.
42
34
  def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
43
-
44
- def described = ("(#{description})" if description.present?)
45
35
  end
46
36
  end
@@ -6,7 +6,5 @@ module Jbr
6
6
  def quantity = whole @node[:quantity]
7
7
 
8
8
  def name = @node[:name]
9
-
10
- def description = @node[:description]
11
9
  end
12
10
  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.0'
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.0
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