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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +40 -14
- data/lib/graphql/client.rb +1 -5
- data/lib/jbr/mock/jobs.rb +8 -2
- data/lib/jbr/mock/visits.rb +5 -0
- data/lib/jbr/oauth.rb +10 -16
- 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 -1
- metadata +2 -2
- data/lib/jbr/throttle.rb +0 -44
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,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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
data/lib/graphql/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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,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
|
|
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,
|
|
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
|
-
|
|
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
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.
|
|
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
|