jbr 3.6.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: b8ab569aab529193190b37620954eb2e95db5ac39ccb2a085a7caa2b6cf66981
4
- data.tar.gz: 4d331b9c225a2d0fb8a978f166985f416528a23eac283c4afb31924aecf7bc8b
3
+ metadata.gz: a8a11f9cbda31f995ef0aa78f55db8502f7f5077fa1703db261f9a7705fea971
4
+ data.tar.gz: 6d178b20c67e47c33e5d4617b0fc1c8cc5637794de9ce3b4a94c0af407b8bab6
5
5
  SHA512:
6
- metadata.gz: fbe6e4b68d03262584a83279ca00eff9a4a5886413498d5db6cbec643e070d12a863188914e95c87a9365fe8f7aa419796c4238824d3346847fdbbd71960c74e
7
- data.tar.gz: c0a9624a47f0c4f3706acdabf95bab82cb9d6e2deef035e88edb08daa89d4cdb686bf6489ea6e3c5fcd5b67dcc2cae4388ed19d4f6db036144366a6bcf995864
6
+ metadata.gz: c2696a88809c0e13f8da7b7b49a7cedb59bde16d695f233a5719cac00ff83af779a1aeca7ee106d518b0301ef89bb46049ed54fc86a9b4e6a28c216a5e6c57d7
7
+ data.tar.gz: 2a50c2a089b340ff61d0723a845d12abc653ef7606e80c9963e20ff62c830ec7541039becb9e4999b883dba6e61b5edb323f96435eca25d2ce1ae6f63866719f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
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
+
1
31
  ## [3.6.1] - 2026-08-17
2
32
 
3
33
  - [Fix] 3.6.0 shipped without the two files it added, `graphql/throttled` and `jbr/asking`, so
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,31 +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:
198
-
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.
203
-
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.
206
-
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:
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:
209
225
 
210
226
  ```
211
227
  Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
212
228
  ```
213
229
 
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.
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.
235
+
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.
220
238
 
221
239
  ### Events
222
240
 
@@ -16,37 +16,21 @@ 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
- raise refusal_for(body), refusal(body) if body['errors'].present?
25
+ raise Error, refusal(body) if body['errors'].present?
30
26
  body.fetch('data')
31
27
  end
32
28
 
33
29
  private
34
30
 
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
-
47
31
  # What the endpoint refused, and — where it priced the refusal — what the query would have
48
32
  # cost against what was available. `Throttled` on its own leaves a caller unable to tell a
49
- # 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.
50
34
  def refusal(body)
51
35
  message = body['errors'].map { |error| error['message'] }.join '; '
52
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,7 +1,7 @@
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
4
+ include Refreshing
5
5
 
6
6
  # The mutation that revokes the app on the account.
7
7
  DISCONNECT_MUTATION = <<~GRAPHQL
@@ -13,13 +13,15 @@ module Jbr
13
13
  }
14
14
  GRAPHQL
15
15
 
16
- # @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}.
17
18
  def initialize(credentials = {})
18
19
  @access_token = credentials[:access_token]
19
20
  @refresh_token = credentials[:refresh_token]
20
21
  @expires_at = credentials[:expires_at]
21
22
  @account_id = credentials[:account_id]
22
23
  @invalid_at = credentials[:invalid_at]
24
+ @store = credentials[:store]
23
25
  end
24
26
 
25
27
  # The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
@@ -36,6 +38,22 @@ module Jbr
36
38
  def requests = Request.new oauth: self
37
39
  def visits = Visits.new oauth: self
38
40
 
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.
45
+ # @return [Hash] the data Jobber answered, or empty when the credentials are dead.
46
+ def query(statement, variables: {})
47
+ client.query statement, variables: variables
48
+ rescue GraphQL::Unauthorized
49
+ refresh ? retry : {}
50
+ rescue GraphQL::Error => error
51
+ # The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
52
+ # was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
53
+ # up instead of hearing that Jobber would not answer.
54
+ raise Error, error.message
55
+ end
56
+
39
57
  # Delete a token. If the token is invalid, do nothing.
40
58
  def delete
41
59
  client.query DISCONNECT_MUTATION
@@ -64,16 +82,6 @@ module Jbr
64
82
 
65
83
  private
66
84
 
67
- def refresh
68
- output = self.class.post refresh_token: @refresh_token, grant_type: 'refresh_token'
69
- @access_token = output[:access_token]
70
- @refresh_token = output[:refresh_token]
71
- @expires_at = output[:expires_at]
72
- rescue Refused
73
- @invalid_at = Time.now
74
- false
75
- end
76
-
77
85
  def client
78
86
  GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
79
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.6.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
@@ -11,7 +11,6 @@ require 'active_support/core_ext/array/conversions'
11
11
 
12
12
  require 'graphql/error'
13
13
  require 'graphql/unauthorized'
14
- require 'graphql/throttled'
15
14
  require 'graphql/client'
16
15
 
17
16
  require 'jbr/mock'
@@ -20,14 +19,13 @@ require 'jbr/url'
20
19
  require 'jbr/error'
21
20
  require 'jbr/refused'
22
21
  require 'jbr/token'
22
+ require 'jbr/refreshing'
23
23
  # Phone before Cliental, and Cliental before the records that include it: what each asks
24
24
  # Jobber for about a client is built as they load.
25
25
  require 'jbr/phone'
26
26
  require 'jbr/cliental'
27
27
  require 'jbr/named'
28
28
  require 'jbr/resource'
29
- require 'jbr/throttle'
30
- require 'jbr/asking'
31
29
  require 'jbr/request'
32
30
  require 'jbr/oauth'
33
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.6.1
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -106,11 +106,9 @@ files:
106
106
  - README.md
107
107
  - lib/graphql/client.rb
108
108
  - lib/graphql/error.rb
109
- - lib/graphql/throttled.rb
110
109
  - lib/graphql/unauthorized.rb
111
110
  - lib/jbr.rb
112
111
  - lib/jbr/account.rb
113
- - lib/jbr/asking.rb
114
112
  - lib/jbr/client.rb
115
113
  - lib/jbr/cliental.rb
116
114
  - lib/jbr/error.rb
@@ -142,10 +140,10 @@ files:
142
140
  - lib/jbr/properted.rb
143
141
  - lib/jbr/property.rb
144
142
  - lib/jbr/quote.rb
143
+ - lib/jbr/refreshing.rb
145
144
  - lib/jbr/refused.rb
146
145
  - lib/jbr/request.rb
147
146
  - lib/jbr/resource.rb
148
- - lib/jbr/throttle.rb
149
147
  - lib/jbr/token.rb
150
148
  - lib/jbr/url.rb
151
149
  - lib/jbr/version.rb
@@ -1,6 +0,0 @@
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 DELETED
@@ -1,40 +0,0 @@
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/throttle.rb DELETED
@@ -1,52 +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
- @maximum = status['maximumAvailable'].to_f
29
- @restore_rate = status['restoreRate'].to_f
30
- end
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
-
39
- private
40
-
41
- # Nothing is owed to the first caller: a request that follows no other is not too soon.
42
- def spacing_owed = (@asked_at ? [ SPACING - (Time.now - @asked_at), 0.0 ].max : 0.0)
43
-
44
- # What the last answer cost is what the next one is taken to cost, since a walk asks the
45
- # same query of every page. Where the bucket cannot pay for it, wait for it to refill.
46
- def restore_owed
47
- return 0.0 unless @restore_rate.to_f.positive? && @available.to_f < @cost.to_f
48
-
49
- (@cost - @available) / @restore_rate
50
- end
51
- end
52
- end