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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +38 -20
- data/lib/graphql/client.rb +2 -18
- data/lib/jbr/mock/jobs.rb +8 -2
- data/lib/jbr/mock/visits.rb +5 -0
- data/lib/jbr/oauth.rb +20 -12
- data/lib/jbr/refreshing.rb +48 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr/visits.rb +17 -0
- data/lib/jbr.rb +1 -3
- metadata +2 -4
- data/lib/graphql/throttled.rb +0 -6
- data/lib/jbr/asking.rb +0 -40
- data/lib/jbr/throttle.rb +0 -52
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8a11f9cbda31f995ef0aa78f55db8502f7f5077fa1703db261f9a7705fea971
|
|
4
|
+
data.tar.gz: 6d178b20c67e47c33e5d4617b0fc1c8cc5637794de9ce3b4a94c0af407b8bab6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
197
|
-
|
|
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,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
data/lib/graphql/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
#
|
|
5
|
-
|
|
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
|
data/lib/jbr/mock/visits.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
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.
|
|
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
|
data/lib/graphql/throttled.rb
DELETED
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
|