jbr 3.5.1 → 3.7.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: f5c1536ff754171e5bafc8e5501088b0372c673c98e3891786b43da0a69f5c75
4
- data.tar.gz: 61bf56b9de47ec4ada6e406967c36240a95351d50a007730ce2506c0d7244864
3
+ metadata.gz: a8a11f9cbda31f995ef0aa78f55db8502f7f5077fa1703db261f9a7705fea971
4
+ data.tar.gz: 6d178b20c67e47c33e5d4617b0fc1c8cc5637794de9ce3b4a94c0af407b8bab6
5
5
  SHA512:
6
- metadata.gz: 52d0070f75aa56d58874e301c0bb11b8b78fb108ac2327617094f9ee3676302fb8b59dee89942203dc993305356a43165b0fb053ac0257f1cb0f1299949ad5b3
7
- data.tar.gz: 73c45d7ed55f796c7e0068eb19c33bc07e0a1ac2359a594b12eca096b43e8bf86058e4f67b3f03877d02e337a87ee81be3efca500df6deb6b9470a175065bdbc
6
+ metadata.gz: c2696a88809c0e13f8da7b7b49a7cedb59bde16d695f233a5719cac00ff83af779a1aeca7ee106d518b0301ef89bb46049ed54fc86a9b4e6a28c216a5e6c57d7
7
+ data.tar.gz: 2a50c2a089b340ff61d0723a845d12abc653ef7606e80c9963e20ff62c830ec7541039becb9e4999b883dba6e61b5edb323f96435eca25d2ce1ae6f63866719f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,56 @@
1
+ ## [3.7.0] - 2026-08-17
2
+
3
+ - [New] `store:`, for credentials several processes hold copies of. A queue of workers each
4
+ building its own `oauth_for` used to refresh a hundred times over when the access token
5
+ expired, each spending a refresh token the first had already spent — and Jobber calls a
6
+ spent one a dead grant, so all but one of them would mark the credentials invalid and the
7
+ whole connection would be discarded. With a store the refresh happens under the app's own
8
+ lock, against the credentials as they are at that moment, and a holder that finds the token
9
+ already replaced adopts it without asking Jobber anything
10
+ - [New] `Jbr::Visits#find`, reaching one visit by the ID Jobber files it under, the way jobs
11
+ already could. An app importing many of them can walk the account for IDs alone and then
12
+ fetch each visit on its own
13
+ - [Change] The refresh moved to `Jbr::Refreshing`, which is what `OAuth` includes to do it
14
+
15
+ ## [3.6.2] - 2026-08-17
16
+
17
+ - [Change] Nothing in this gem sleeps any more. 3.6.0 waited out a refusal for cost and asked
18
+ again, and 3.2.0 spaced every request 0.12s from the one before — both of which put a worker
19
+ to sleep, often with a transaction open around it. A caller asking from a background job has
20
+ a queue that will bring the whole job back later, and that is worth more than a held worker,
21
+ so a refusal is raised and the decision is the caller's. `Jbr::Throttle` and
22
+ `GraphQL::Throttled` go with the waiting; the numbers a refusal reports stay in its message
23
+ - [Fix] A mocked `find` answers with the job on `Jbr.mock.jobs` filed under that ID, falling
24
+ back to `Jbr.mock.job` as before. An app that lists jobs and then looks one of them up — one
25
+ request for the IDs, then one job at a time — used to get whichever single job it had mocked,
26
+ whatever ID it asked for
27
+ - [Change] Without the request spacing, an app walking many pages is on its own about Jobber's
28
+ other limit, 2,500 requests every five minutes. Nothing here has come close to it: the
29
+ spacing was insurance against a walk that no longer exists
30
+
31
+ ## [3.6.1] - 2026-08-17
32
+
33
+ - [Fix] 3.6.0 shipped without the two files it added, `graphql/throttled` and `jbr/asking`, so
34
+ requiring the gem raised `LoadError` and nothing worked at all. The gem's file list comes
35
+ from `git ls-files` and the release commit never added them. 3.6.0 is yanked
36
+
37
+ ## [3.6.0] - 2026-08-17
38
+
39
+ - [Fix] A refusal for cost the bucket can recover from is waited out and asked again, up to
40
+ four times, rather than raised. A walk of many pages drains the bucket faster than it
41
+ refills and gets `Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)` — 631
42
+ points short of a query the bucket holds five times over. The refusal prices the query, so
43
+ the throttle already knows the shortfall: 1.26 seconds at 500 a second, and the same
44
+ question is answered
45
+ - [Change] Only a query costing more than the bucket *ever* holds is given up on, since
46
+ waiting cannot help it. Where Jobber names no ceiling, one wait is tried rather than the
47
+ worst assumed
48
+ - [New] `GraphQL::Throttled`, a `GraphQL::Error` for a query refused over what it costs rather
49
+ than over anything about the query. It never leaves the gem — a caller still sees
50
+ `Jbr::Error` — but it is what tells the two refusals apart inside it
51
+ - [Change] What credentials do when they ask Jobber something is `Jbr::Asking`, mixed into
52
+ `Jbr::OAuth`, which was over a hundred lines with the retry in it
53
+
1
54
  ## [3.5.1] - 2026-08-17
2
55
 
3
56
  - [Change] A line item is how many of what, and nothing else. `description` was answered and
data/README.md CHANGED
@@ -26,6 +26,32 @@ Initialize with existing credentials:
26
26
  oauth = Jbr.oauth_for access_token:, refresh_token:, expires_at:, account_id:
27
27
  ```
28
28
 
29
+ Where several processes hold copies of the same credentials — a queue of workers, each
30
+ building its own — hand over a `store:` as well, and only one of them will ever spend the
31
+ refresh token:
32
+
33
+ ```ruby
34
+ oauth = Jbr.oauth_for account_id:, invalid_at:, store: credentials
35
+ ```
36
+
37
+ The store is anything answering two methods. `exclusively` takes whatever lock the app keeps
38
+ over those credentials and yields them *as they are right now*, read inside that lock; `write`
39
+ records the ones Jobber handed back:
40
+
41
+ ```ruby
42
+ def exclusively = with_lock { yield oauth_params } # Active Record, in an app that has it
43
+
44
+ def write(oauth) = update oauth: oauth
45
+ ```
46
+
47
+ An expired access token is then refreshed once. Every other holder takes the lock, finds a
48
+ token that is no longer the one it tried, adopts it, and asks Jobber nothing — where without a
49
+ store each of them would spend a refresh token the first has already spent, and Jobber would
50
+ call every one of those a dead grant.
51
+
52
+ `exclusively` has to be exclusive against every **process** sharing the credentials, not just
53
+ every thread: a `Mutex` satisfies this interface and fixes nothing on a fleet of workers.
54
+
29
55
  Access OAuth attributes:
30
56
 
31
57
  ```ruby
@@ -192,23 +218,23 @@ query never named a client.
192
218
 
193
219
  ### Rate limits
194
220
 
195
- Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket
196
- of query cost that drains as it is asked and refills at a rate it reports. Nothing has to be
197
- done about either — every request waits for itself:
221
+ Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket of
222
+ query cost that drains as it is asked and refills at a rate it reports. This gem does nothing
223
+ about either — it never sleeps, and it never asks a second time. What it does is say exactly
224
+ what happened, so the caller can decide:
198
225
 
199
- - It spaces itself 0.12 seconds from the request before, which is 2,500 spread evenly over
200
- five minutes.
201
- - It reads `extensions.cost` off each answer, and where the bucket can no longer pay for a
202
- page like the last one, it waits for the shortfall to refill at Jobber's own restore rate.
226
+ ```
227
+ Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
228
+ ```
203
229
 
204
- A request that follows no other waits for nothing, so a single `find` is as quick as it ever
205
- was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
230
+ That arrives as a `Jbr::Error`. 631 points short of a query the bucket holds five times over,
231
+ which a second would have refilled worth asking again. A cost above `maximumAvailable` is
232
+ worth nothing but a smaller query. Either way the decision belongs to whoever called: from a
233
+ background job, letting it fail so the queue brings it back is better than a worker asleep
234
+ holding a transaction open.
206
235
 
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.
236
+ Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
237
+ twenty lines to a job, and twenty jobs or visits to a page.
212
238
 
213
239
  ### Events
214
240
 
@@ -16,16 +16,12 @@ module GraphQL
16
16
 
17
17
  # @param query [String] the GraphQL query string.
18
18
  # @param variables [Hash] the variables to interpolate into the query.
19
- # @yield [Hash] the `extensions` the endpoint answered beside the data, where it did.
20
19
  # @return [Hash] the `data` portion of the GraphQL response.
21
20
  def query(query, variables: {})
22
21
  response = Net::HTTP.post @endpoint, { query:, variables: }.to_json, request_headers
23
22
  raise Unauthorized, response.body if response.code == '401'
24
23
  raise Error, response.body unless response.is_a? Net::HTTPSuccess
25
24
  body = JSON.parse(response.body)
26
- # Before the refusal, not after it: an endpoint that reports what it will still answer
27
- # reports it when it says no, which is when a caller most needs to know.
28
- yield body['extensions'] if block_given?
29
25
  raise Error, refusal(body) if body['errors'].present?
30
26
  body.fetch('data')
31
27
  end
@@ -34,7 +30,7 @@ module GraphQL
34
30
 
35
31
  # What the endpoint refused, and — where it priced the refusal — what the query would have
36
32
  # cost against what was available. `Throttled` on its own leaves a caller unable to tell a
37
- # query too big to ever run from a bucket that only needed a moment.
33
+ # query too big to ever run from a bucket that a moment would have refilled.
38
34
  def refusal(body)
39
35
  message = body['errors'].map { |error| error['message'] }.join '; '
40
36
  cost = body['extensions'].to_h['cost'].to_h
data/lib/jbr/mock/jobs.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  module Jbr
2
2
  # The jobs an app under test asked {Jbr.mock} to answer with.
3
3
  class Mock::Jobs < Jobs
4
- # @return [Mock::Job] the one job the app named, whatever ID is asked for.
5
- def find(_) = Mock::Job.new node: Jbr.mock.job
4
+ # The job filed under that ID where the app listed one, and otherwise the single job it
5
+ # named which is every app that mocks a lookup without mocking a list.
6
+ # @return [Mock::Job] the job asked for.
7
+ def find(id) = Mock::Job.new node: listed(id) || Jbr.mock.job
6
8
 
7
9
  # @return [Enumerator<Mock::Job>] every job the app named.
8
10
  def each(&) = mocked(Jbr.mock.jobs).each(&)
@@ -20,5 +22,9 @@ module Jbr
20
22
  end
21
23
 
22
24
  def started?(job) = job[:scheduled_at] ? job[:scheduled_at] <= Time.now : false
25
+
26
+ # Only a real list is looked through: an app that mocked the list as something raising
27
+ # was mocking the walk failing, and a lookup is a question of its own.
28
+ def listed(id) = (Jbr.mock.jobs.find { |job| job[:id] == id } if Jbr.mock.jobs.is_a? Array)
23
29
  end
24
30
  end
@@ -10,6 +10,9 @@ module Jbr
10
10
  # @return [Enumerator<Mock::Visit>] those it dated before now.
11
11
  def past = mocked Jbr.mock.visits.select { |visit| started? visit }
12
12
 
13
+ # @return [Mock::Visit] the visit the app listed under that ID.
14
+ def find(id) = Mock::Visit.new node: listed(id)
15
+
13
16
  private
14
17
 
15
18
  def mocked(visits)
@@ -17,5 +20,7 @@ module Jbr
17
20
  end
18
21
 
19
22
  def started?(visit) = visit[:starts_at] ? visit[:starts_at] <= Time.now : false
23
+
24
+ def listed(id) = Jbr.mock.visits.to_a.find { |visit| visit[:id] == id }.to_h
20
25
  end
21
26
  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 Refreshing
5
+
4
6
  # The mutation that revokes the app on the account.
5
7
  DISCONNECT_MUTATION = <<~GRAPHQL
6
8
  mutation Disconnect {
@@ -11,13 +13,15 @@ module Jbr
11
13
  }
12
14
  GRAPHQL
13
15
 
14
- # @param credentials [Hash] the tokens, their expiry, the account and when it went bad.
16
+ # @param credentials [Hash] the tokens, their expiry, the account, when it went bad, and
17
+ # the `store:` these are kept in, where processes share them. See {Refreshing}.
15
18
  def initialize(credentials = {})
16
19
  @access_token = credentials[:access_token]
17
20
  @refresh_token = credentials[:refresh_token]
18
21
  @expires_at = credentials[:expires_at]
19
22
  @account_id = credentials[:account_id]
20
23
  @invalid_at = credentials[:invalid_at]
24
+ @store = credentials[:store]
21
25
  end
22
26
 
23
27
  # The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
@@ -34,11 +38,13 @@ module Jbr
34
38
  def requests = Request.new oauth: self
35
39
  def visits = Visits.new oauth: self
36
40
 
37
- # Run a statement, waiting for what Jobber will still answer and refreshing a stale token.
41
+ # Run a statement, refreshing a stale token. Nothing here ever sleeps: where Jobber holds
42
+ # the app to a limit it says so, and a caller asking from a background job has a queue that
43
+ # will bring the whole job back later — which is worth more than a worker asleep holding a
44
+ # transaction open.
38
45
  # @return [Hash] the data Jobber answered, or empty when the credentials are dead.
39
46
  def query(statement, variables: {})
40
- throttle.wait
41
- client.query(statement, variables: variables) { |extensions| throttle.read extensions }
47
+ client.query statement, variables: variables
42
48
  rescue GraphQL::Unauthorized
43
49
  refresh ? retry : {}
44
50
  rescue GraphQL::Error => error
@@ -76,18 +82,6 @@ module Jbr
76
82
 
77
83
  private
78
84
 
79
- def throttle = @throttle ||= Throttle.new
80
-
81
- def refresh
82
- output = self.class.post refresh_token: @refresh_token, grant_type: 'refresh_token'
83
- @access_token = output[:access_token]
84
- @refresh_token = output[:refresh_token]
85
- @expires_at = output[:expires_at]
86
- rescue Refused
87
- @invalid_at = Time.now
88
- false
89
- end
90
-
91
85
  def client
92
86
  GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
93
87
  token: @access_token, headers: headers
@@ -0,0 +1,48 @@
1
+ module Jbr
2
+ # What credentials do when Jobber says the access token is stale, and how they do it safely
3
+ # where many processes hold copies of the same ones: through a store, only one of them asks
4
+ # Jobber for a new token and the rest take the one it wrote.
5
+ module Refreshing
6
+ private
7
+
8
+ # With no store there is nobody to compare against: refresh, and leave what we end up
9
+ # holding for the caller to persist.
10
+ def refresh
11
+ return exchange unless @store
12
+
13
+ @store.exclusively { |stored| renew stored }
14
+ end
15
+
16
+ # Under the store's lock, holding what it says right now. A token that is no longer the one
17
+ # we tried is one somebody else has already replaced, and adopting it asks Jobber nothing —
18
+ # which is what keeps a queue of workers from refreshing a hundred times over, each with a
19
+ # refresh token the first of them has already spent.
20
+ def renew(stored)
21
+ return adopt stored if stored[:access_token] != @access_token
22
+
23
+ @refresh_token = stored[:refresh_token]
24
+ exchange.tap { |renewed| @store.write self if renewed }
25
+ end
26
+
27
+ def exchange
28
+ adopt self.class.post(refresh_token: @refresh_token, grant_type: 'refresh_token')
29
+ rescue Refused
30
+ refused
31
+ end
32
+
33
+ def adopt(credentials)
34
+ @access_token = credentials[:access_token]
35
+ @refresh_token = credentials[:refresh_token]
36
+ @expires_at = credentials[:expires_at]
37
+ true
38
+ end
39
+
40
+ # Refused while holding the freshest refresh token there is, so the grant itself is dead
41
+ # rather than our copy being behind.
42
+ def refused
43
+ @invalid_at = Time.now
44
+ @store&.write self
45
+ false
46
+ end
47
+ end
48
+ 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.5.1'
4
+ VERSION = '3.7.0'
5
5
  end
data/lib/jbr/visits.rb CHANGED
@@ -16,8 +16,25 @@ module Jbr
16
16
  # @return [Enumerator<Visit>] the visits that started before now.
17
17
  def past = walk until_now
18
18
 
19
+ # Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
20
+ # files it under, not by asking every visit on the account whether it is the one.
21
+ # @param id [String] the Jobber ID of the visit.
22
+ # @return [Visit, nil] nil when Jobber has no visit under that ID.
23
+ def find(id)
24
+ node = @oauth.query(one, variables: { id: id })['visit']
25
+ Visit.new node: node if node
26
+ end
27
+
19
28
  private
20
29
 
30
+ def one
31
+ <<~GRAPHQL
32
+ query($id: EncodedId!) {
33
+ visit(id: $id) { #{FIELDS} #{selections} }
34
+ }
35
+ GRAPHQL
36
+ end
37
+
21
38
  # Twenty a page, the same as jobs: Jobber prices a query by its page size, and what an
22
39
  # includes brings back is charged for on top of every row of it.
23
40
  def page
data/lib/jbr.rb CHANGED
@@ -19,13 +19,13 @@ require 'jbr/url'
19
19
  require 'jbr/error'
20
20
  require 'jbr/refused'
21
21
  require 'jbr/token'
22
+ require 'jbr/refreshing'
22
23
  # Phone before Cliental, and Cliental before the records that include it: what each asks
23
24
  # Jobber for about a client is built as they load.
24
25
  require 'jbr/phone'
25
26
  require 'jbr/cliental'
26
27
  require 'jbr/named'
27
28
  require 'jbr/resource'
28
- require 'jbr/throttle'
29
29
  require 'jbr/request'
30
30
  require 'jbr/oauth'
31
31
 
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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -140,10 +140,10 @@ files:
140
140
  - lib/jbr/properted.rb
141
141
  - lib/jbr/property.rb
142
142
  - lib/jbr/quote.rb
143
+ - lib/jbr/refreshing.rb
143
144
  - lib/jbr/refused.rb
144
145
  - lib/jbr/request.rb
145
146
  - lib/jbr/resource.rb
146
- - lib/jbr/throttle.rb
147
147
  - lib/jbr/token.rb
148
148
  - lib/jbr/url.rb
149
149
  - lib/jbr/version.rb
data/lib/jbr/throttle.rb DELETED
@@ -1,44 +0,0 @@
1
- module Jbr
2
- # How much Jobber will still answer, and how long to wait before asking again. Jobber holds
3
- # an app to two limits at once, and this keeps both: a count of requests over a window, and
4
- # a bucket of query cost that drains as it is asked and refills at a rate it reports.
5
- class Throttle
6
- # Jobber answers 2,500 requests every 5 minutes, which is one every 0.12 seconds. Spacing
7
- # them is what keeps a walk of many pages under the count, whatever each page costs.
8
- SPACING = 300.0 / 2_500
9
-
10
- # @return [Float] the seconds waited, which is zero where nothing was owed.
11
- def wait
12
- owed = [ spacing_owed, restore_owed ].max
13
- sleep owed if owed.positive?
14
- @asked_at = Time.now
15
- owed
16
- end
17
-
18
- # Takes in what the answer said it had left. Jobber reports the bucket beside the data,
19
- # so what the next caller may ask for is known before they ask for it.
20
- # @param extensions [Hash, nil] the +extensions+ Jobber answered beside the data.
21
- def read(extensions)
22
- cost = extensions.to_h['cost'].to_h
23
- status = cost['throttleStatus'].to_h
24
- # What a refused query would have cost is what it costs: Jobber prices it either way,
25
- # and only an answered one reports an actual.
26
- @cost = (cost['actualQueryCost'] || cost['requestedQueryCost']).to_f
27
- @available = status['currentlyAvailable'].to_f
28
- @restore_rate = status['restoreRate'].to_f
29
- end
30
-
31
- private
32
-
33
- # Nothing is owed to the first caller: a request that follows no other is not too soon.
34
- def spacing_owed = (@asked_at ? [ SPACING - (Time.now - @asked_at), 0.0 ].max : 0.0)
35
-
36
- # What the last answer cost is what the next one is taken to cost, since a walk asks the
37
- # same query of every page. Where the bucket cannot pay for it, wait for it to refill.
38
- def restore_owed
39
- return 0.0 unless @restore_rate.to_f.positive? && @available.to_f < @cost.to_f
40
-
41
- (@cost - @available) / @restore_rate
42
- end
43
- end
44
- end