vpndetection 5.4.1 → 5.4.3

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: 9f1e4982b72ed2bd82abdc656375e35282b567aa9b1ca549308b2739ab7334f1
4
- data.tar.gz: ee66d0a96bd81eb8344be23df461bc6969fd12f528ff44717975c0e85b627db8
3
+ metadata.gz: ba80a879812af8dc9d6cdefdb305d97fe38281550cad1b2c9bd76ab31b416bf2
4
+ data.tar.gz: 602fb1b3b602fa0ec12ca8d465dcda92e22d87610714ca31ec735033a83b61f6
5
5
  SHA512:
6
- metadata.gz: 0473751a9f0be66de9b1ef36e383eb5b27f24c3be1c54d33defb4d09c331d6bbfc8b48e55e62a7442d2ae8575fd02f43250d402c8afe790a98359095c0d4f0b9
7
- data.tar.gz: d070aac3cc244abbb3f3fe2342f2b9812e4f73256573b5d1a144627ecc7afee34866844edfe260f564e50c33f397f3d8333f299a628f34a590f756ec773e50c5
6
+ metadata.gz: 340fbe8a1a6265f5dec5d5e93fecd2c63c171af83699fdc073ac98410d83e2ec4924c2b358c4a30785f3aeebb8b606cbefa0eeba0c54f402fda60a03dda1f843
7
+ data.tar.gz: eae9ca20b51fbb6d12060add25b281625c5096e9b54fd7ef1b596a8c54c476589fc09662c117c4eeb879556482fe7caa9056c0b81581af98d739079df544d557
data/README.md CHANGED
@@ -150,7 +150,7 @@ end
150
150
 
151
151
  `kind` is one of `:bad_request`, `:unauthorized`, `:forbidden`, `:rate_limited`, `:quota_exceeded`, `:server_error` or `:network`.
152
152
 
153
- Note that `:rate_limited` and `:quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is when the API faces extreme traffic bursts and so retrying later works; but a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, but not if your quota is exceeded.
153
+ Note that `:rate_limited` and `:quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is when the API faces extreme traffic bursts and so retrying later works; but a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, but not if your quota is exceeded. It waits the `Retry-After` the API sent, or its own backoff from 250 ms when that's past about 24.8 days.
154
154
 
155
155
  ### Timeouts and retries
156
156
 
@@ -160,7 +160,7 @@ client = VPNDetection::Client.new(timeout: 10, retries: 4)
160
160
  result = client.lookup('45.83.91.1', timeout: 2, retries: 0)
161
161
  ```
162
162
 
163
- `timeout` is in seconds and bounds each attempt, body included, so a call that is retried can take longer in total. It defaults to 30 seconds; before 5.2.0 the default was 10, so pass `timeout: 10` to keep that bound. The client's values are defaults: `lookup`, `lookup_batch`, `my_ip` and `my_entitlement` each take `timeout:` and `retries:` for that call alone, and every `client.oauth` method and every `client.database` call that is not a transfer takes `timeout:` (from 5.3.0). A database download bounds only its connection with it, because a whole transfer can take minutes.
163
+ `timeout` is in seconds and bounds each attempt, body included, so a call that is retried can take longer in total. It defaults to 30 seconds, and 0 means no bound; before 5.2.0 the default was 10, so pass `timeout: 10` to keep that bound. The client's values are defaults: `lookup`, `lookup_batch`, `my_ip` and `my_entitlement` each take `timeout:` and `retries:` for that call alone, and every `client.oauth` method and every `client.database` call that is not a transfer takes `timeout:` (from 5.3.0). A database download bounds only its connection with it, because a whole transfer can take minutes. A negative value, anything that isn't a number, and anything past 2147483.647 seconds (the longest curl holds) raise `ArgumentError` where you pass them, before any request.
164
164
 
165
165
  ### Database downloads
166
166
 
@@ -29,11 +29,17 @@ module VPNDetection
29
29
  # @param cache [Boolean] pass false to disable caching.
30
30
  # @param cache_ttl [Numeric] how long an answer stays fresh, in seconds.
31
31
  # @param concurrency [Integer] batch requests - chunks of up to 1000 addresses - in flight during a batch.
32
- # @param retries [Integer] extra attempts for a transient failure.
32
+ # @param retries [Integer] extra attempts for a transient failure. Each waits
33
+ # the server's `Retry-After` when it sent one of at most about 24.8 days,
34
+ # and otherwise a backoff of 250 ms that doubles per retry.
33
35
  # @param timeout [Numeric] seconds one request may take before it is
34
- # abandoned. Applies per ATTEMPT, so a retried call may take longer in
35
- # total, and every call that takes `retries:` also takes `timeout:` to
36
- # override it. A dataset transfer bounds only its connect phase with it.
36
+ # abandoned, 0 for no bound. Applies per ATTEMPT, so a retried call may take
37
+ # longer in total, and every call that takes `retries:` also takes
38
+ # `timeout:` to override it. A dataset transfer bounds only its connect
39
+ # phase with it.
40
+ # @raise [ArgumentError] for a `timeout`, here or on any call, that is
41
+ # negative, not a finite number, or past 2147483.647 seconds (2**31 - 1 ms),
42
+ # the longest curl holds.
37
43
  # @param transport [Transport, nil] override the HTTP layer, mostly for tests.
38
44
  def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, cache: true,
39
45
  cache_max_size: DEFAULT_CACHE_MAX_SIZE, cache_ttl: DEFAULT_CACHE_TTL,
@@ -43,6 +49,9 @@ module VPNDetection
43
49
  Transport::Config.new(api_key: api_key, base_url: base_url, timeout: timeout),
44
50
  )
45
51
  @cache = cache ? Cache.new(max_size: cache_max_size, ttl: cache_ttl) : nil
52
+ # The addresses with a request in flight, each a Flight its waiters block on.
53
+ @flights = {}
54
+ @flights_lock = Mutex.new
46
55
  @concurrency = concurrency
47
56
  @retries = retries
48
57
  @database = DatabaseApi.new(@transport, retries: retries)
@@ -68,16 +77,30 @@ module VPNDetection
68
77
  # @param retries [Integer, nil] extra attempts, for THIS call only.
69
78
  # @param timeout [Numeric, nil] seconds each attempt may take, for THIS call only.
70
79
  def lookup(ip, retries: nil, timeout: nil)
80
+ # Here as well as in the transport: a bogon or a cached answer returns before any request.
81
+ Transport.checked_timeout(timeout) unless timeout.nil?
71
82
  return Bogon.result(ip) if Bogon.bogon?(ip)
72
83
 
73
- hit = @cache&.get(ip)
74
- return hit unless hit.nil?
84
+ loop do
85
+ hit = @cache&.get(ip)
86
+ return hit unless hit.nil?
75
87
 
76
- result = Retries.with_retries(retries || @retries) do
77
- Transport.lookup_result(@transport.lookup_request(ip, timeout: timeout).run)
88
+ flight, leader = board(ip)
89
+ return fetch(ip, retries, timeout) if flight.nil?
90
+
91
+ if leader
92
+ # Read once more now the address is boarded: a leader that landed
93
+ # between the miss above and boarding has already cached its answer.
94
+ hit = @cache.get(ip)
95
+ return unboard(ip, flight, :served, hit) unless hit.nil?
96
+
97
+ return lead(ip, flight) { fetch(ip, retries, timeout) }
98
+ end
99
+ state, value = flight.wait
100
+ return value if state == :served
101
+ raise value if state == :failed
102
+ # :abandoned - its leader never finished, so this caller asks again.
78
103
  end
79
- @cache&.set(ip, result)
80
- result
81
104
  end
82
105
 
83
106
  # Classify the address this client is calling from.
@@ -142,6 +165,7 @@ module VPNDetection
142
165
  unless limit.is_a?(Numeric) && limit >= 1
143
166
  raise Error.new(:bad_request, "concurrency must be at least 1, not #{limit.inspect}")
144
167
  end
168
+ Transport.checked_timeout(timeout) unless timeout.nil?
145
169
 
146
170
  addresses = ips.to_a.uniq
147
171
  answers = {}
@@ -151,9 +175,27 @@ module VPNDetection
151
175
  hit = Bogon.bogon?(ip) ? Bogon.result(ip) : @cache&.get(ip)
152
176
  hit.nil? ? pending << ip : answers[ip] = hit
153
177
  end
154
- unless pending.empty?
155
- run_batch(pending.each_slice(BATCH_MAX).to_a, answers, limit, retries || @retries, timeout)
178
+ # Boarded before any chunk is built, so a lookup arriving meanwhile waits
179
+ # for this batch; an address already in flight is not sent again.
180
+ boarded = {}
181
+ joined = {}
182
+ pending.each do |ip|
183
+ flight, leader = board(ip)
184
+ if leader == false
185
+ joined[ip] = flight
186
+ else
187
+ boarded[ip] = flight
188
+ end
189
+ end
190
+ begin
191
+ unless boarded.empty?
192
+ chunks = boarded.keys.each_slice(BATCH_MAX).to_a
193
+ run_batch(chunks, answers, limit, retries || @retries, timeout, boarded)
194
+ end
195
+ ensure
196
+ boarded.each { |ip, flight| unboard(ip, flight, :abandoned) unless flight.nil? }
156
197
  end
198
+ joined.each { |ip, flight| answers[ip] = joined_answer(ip, flight, retries, timeout) }
157
199
 
158
200
  # Reinstated in input order: a hydra settles in completion order, and a
159
201
  # caller iterating the hash should see what they passed in.
@@ -162,11 +204,83 @@ module VPNDetection
162
204
 
163
205
  private
164
206
 
207
+ # Concurrent misses for one address share ONE request (docs/sdk/contract.md,
208
+ # UMAN-4645): 5.4.2 sent one per calling thread. The first miss boards the
209
+ # address and every miss after it, a batch's included, waits on its Flight.
210
+ class Flight
211
+ def initialize
212
+ @lock = Mutex.new
213
+ @landed = ConditionVariable.new
214
+ @state = :pending
215
+ end
216
+
217
+ # Settles the flight once, for every waiter: :served with a Result,
218
+ # :failed with an Error, or :abandoned when its leader never finished.
219
+ def land(state, value = nil)
220
+ @lock.synchronize do
221
+ next unless @state == :pending
222
+
223
+ @state = state
224
+ @value = value
225
+ @landed.broadcast
226
+ end
227
+ end
228
+
229
+ # Blocks until the flight lands, then [state, value].
230
+ def wait
231
+ @lock.synchronize do
232
+ @landed.wait(@lock) while @state == :pending
233
+ [@state, @value]
234
+ end
235
+ end
236
+ end
237
+
238
+ # [flight, true] when this caller leads the request for `ip`, [flight,
239
+ # false] when one is already in flight, and nil without a cache: every
240
+ # lookup is then served, so nothing is shared.
241
+ def board(ip)
242
+ return nil if @cache.nil?
243
+
244
+ @flights_lock.synchronize do
245
+ existing = @flights[ip]
246
+ existing ? [existing, false] : [@flights[ip] = Flight.new, true]
247
+ end
248
+ end
249
+
250
+ # Takes `ip` off the board and lands its flight. Returns `value`.
251
+ def unboard(ip, flight, state, value = nil)
252
+ @flights_lock.synchronize { @flights.delete(ip) if @flights[ip].equal?(flight) }
253
+ flight.land(state, value)
254
+ value
255
+ end
256
+
257
+ # Runs the request a flight is waiting on, under the options of the call
258
+ # that leads it. A served answer is cached before it lands; a failure
259
+ # reaches every waiter and is cached for none; and a leader that never
260
+ # finishes - a killed thread, a Timeout.timeout - abandons the flight, so
261
+ # its waiters ask again rather than failing with it.
262
+ def lead(ip, flight)
263
+ unboard(ip, flight, :served, yield)
264
+ rescue Error => e
265
+ unboard(ip, flight, :failed, e)
266
+ raise
267
+ ensure
268
+ unboard(ip, flight, :abandoned)
269
+ end
270
+
271
+ def fetch(ip, retries, timeout)
272
+ result = Retries.with_retries(retries || @retries) do
273
+ Transport.lookup_result(@transport.lookup_request(ip, timeout: timeout).run)
274
+ end
275
+ @cache&.set(ip, result)
276
+ result
277
+ end
278
+
165
279
  # One hydra per call, sized for THIS call. Reusing an instance-level hydra
166
280
  # would silently cap a per-call concurrency at the client's setting, and
167
281
  # would not be safe to drive from two threads either. Each request is one
168
282
  # chunk of up to 1000 addresses.
169
- def run_batch(chunks, answers, concurrency, retries, timeout)
283
+ def run_batch(chunks, answers, concurrency, retries, timeout, boarded)
170
284
  hydra = Typhoeus::Hydra.new(max_concurrency: concurrency)
171
285
  attempts = Hash.new(0)
172
286
 
@@ -174,7 +288,11 @@ module VPNDetection
174
288
  request = @transport.batch_request(chunk, timeout: timeout)
175
289
  request.on_complete do |response|
176
290
  outcome = settle(chunk, response, attempts, retries, enqueue)
177
- answers.merge!(outcome) unless outcome.nil?
291
+ outcome&.each do |ip, answer|
292
+ answers[ip] = answer
293
+ flight = boarded[ip]
294
+ unboard(ip, flight, answer.is_a?(Result) ? :served : :failed, answer) unless flight.nil?
295
+ end
178
296
  end
179
297
  hydra.queue(request)
180
298
  end
@@ -205,6 +323,17 @@ module VPNDetection
205
323
  nil
206
324
  end
207
325
 
326
+ # What a batch takes for an address another call was fetching: that call's
327
+ # answer or error, or, when its leader never finished, its own lookup.
328
+ def joined_answer(ip, flight, retries, timeout)
329
+ state, value = flight.wait
330
+ return value unless state == :abandoned
331
+
332
+ lookup(ip, retries: retries, timeout: timeout)
333
+ rescue Error => e
334
+ e
335
+ end
336
+
208
337
  # Every address lands in exactly one of `results` and `errors`; an address in
209
338
  # neither is the server breaking its own contract, and is reported as such
210
339
  # rather than lost.
@@ -7,7 +7,8 @@ module VPNDetection
7
7
  # needs a key carrying the `db.download` scope.
8
8
  #
9
9
  # Every JSON call here takes `timeout:`, in seconds, bounding each ATTEMPT of
10
- # that call alone and overriding the bound the client was built with. The two
10
+ # that call alone and overriding the bound the client was built with; one the
11
+ # client would refuse raises ArgumentError before any request. The two
11
12
  # transfers take none, and are refused it rather than ignoring it: a dataset
12
13
  # runs to gigabytes and minutes, so a bound that suits a JSON call would
13
14
  # abandon a healthy download.
@@ -17,6 +17,8 @@ module VPNDetection
17
17
  TOKEN_PATH = '/oauth/token'
18
18
  REVOKE_PATH = '/oauth/revoke'
19
19
  DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
20
+ # The longest single `sleep` the poll asks Ruby for, in seconds.
21
+ LONGEST_SLEEP = 2**31 - 1
20
22
 
21
23
  # The members a 2xx must carry for its type to mean anything.
22
24
  REQUIRED = {
@@ -29,7 +31,7 @@ module VPNDetection
29
31
  @transport = transport
30
32
  @retries = retries
31
33
  # The poll's wait and its monotonic clock, which a test replaces together.
32
- @wait = ->(seconds) { sleep(seconds) }
34
+ @wait = ->(seconds) { sleep_in_parts(seconds) }
33
35
  @now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
34
36
  end
35
37
 
@@ -96,7 +98,8 @@ module VPNDetection
96
98
  # Wait for the person to approve a device sign-in, and return its tokens.
97
99
  #
98
100
  # Waits `device.interval` seconds before EVERY exchange, the first included,
99
- # and five seconds longer for good each time the server answers `slow_down`.
101
+ # and five seconds longer for good each time the server answers `slow_down`,
102
+ # but never past `device.expires_in`: a wait that would end later ends then.
100
103
  # Raises {OauthAccessDeniedError} when the person refuses and
101
104
  # {OauthExpiredTokenError} when the code expires, including locally, with no
102
105
  # status, once `device.expires_in` seconds have passed since this call. Any
@@ -109,10 +112,14 @@ module VPNDetection
109
112
  # @param timeout [Numeric, nil] bounds each exchange, never the whole wait.
110
113
  # @return [TokenResponse]
111
114
  def poll_device_token(client_id, device, timeout: nil)
115
+ Transport.checked_timeout(timeout) unless timeout.nil?
112
116
  interval = device.interval >= 1 ? device.interval : 5
113
117
  deadline = @now.call + device.expires_in
114
118
  loop do
115
- @wait.call(interval)
119
+ # A wait that would end past the deadline waits only the time left, never
120
+ # a negative remainder, and the local expiry below follows with no request.
121
+ left = deadline - @now.call
122
+ @wait.call(interval < left ? interval : [left, 0].max)
116
123
  raise OauthExpiredTokenError, 'expired_token' if @now.call >= deadline
117
124
 
118
125
  begin
@@ -129,6 +136,17 @@ module VPNDetection
129
136
 
130
137
  private
131
138
 
139
+ # `sleep` raises RangeError for a Float from about 9.2e18 s and an Integer
140
+ # past 2**63 - 1, and a server's `expires_in` can leave a wait longer than
141
+ # either, so a long one is slept in parts rather than ending the poll.
142
+ def sleep_in_parts(seconds)
143
+ while seconds.positive?
144
+ part = [seconds, LONGEST_SLEEP].min
145
+ sleep(part)
146
+ seconds -= part
147
+ end
148
+ end
149
+
132
150
  def exchange(form, timeout)
133
151
  decode(TokenResponse, @transport.oauth_request(:POST, TOKEN_PATH, form: form, timeout: timeout).run)
134
152
  end
@@ -9,6 +9,10 @@ module VPNDetection
9
9
  module Retries
10
10
  BACKOFF_SECONDS = 0.25
11
11
 
12
+ # The longest `Retry-After` honored, in seconds: 2**31 - 1 ms, about 24.8
13
+ # days, the same bound as the .NET, erlang, perl and php SDKs'.
14
+ LONGEST_WAIT = 2_147_483.647
15
+
12
16
  module_function
13
17
 
14
18
  # `retry_if`, when given, is asked after each retryable failure, and a false
@@ -26,10 +30,16 @@ module VPNDetection
26
30
  end
27
31
  end
28
32
 
29
- # A server-supplied delay always wins, including a `Retry-After: 0`, which
30
- # is the server saying "immediately" rather than saying nothing.
33
+ # A server-supplied delay wins, including a `Retry-After: 0`, which is the
34
+ # server saying "immediately" rather than saying nothing. One past
35
+ # LONGEST_WAIT is waited out on the backoff instead, still rate_limited:
36
+ # honored, `2147484` held the call for 24.8 days, and `sleep` raised a raw
37
+ # RangeError for `9223372036854775807` and `1e400`.
31
38
  def delay_for(error, attempt)
32
- error.retry_after_seconds || BACKOFF_SECONDS * (2**(attempt - 1))
39
+ asked = error.retry_after_seconds
40
+ return asked if asked && asked <= LONGEST_WAIT
41
+
42
+ BACKOFF_SECONDS * (2**(attempt - 1))
33
43
  end
34
44
  end
35
45
  end
@@ -17,6 +17,23 @@ module VPNDetection
17
17
  ENTITLEMENT_PATH = '/api/v1/entitlement'
18
18
  BATCH_PATH = '/batch'
19
19
 
20
+ # The longest `timeout` curl holds, in seconds: 2**31 - 1 ms, about 24.8 days.
21
+ # libcurl refuses a longer one, and any negative, and Ethon ignores the
22
+ # refusal, so the call would run with no bound at all.
23
+ LONGEST_TIMEOUT = 2_147_483.647
24
+
25
+ # `timeout`, once it is a bound curl can hold: seconds, 0 for none. Refused
26
+ # where it is set, because nothing downstream refuses it: a negative ran with
27
+ # no bound, and NaN, Infinity, a number past curl's ceiling or a string failed
28
+ # every call with an error from Ruby, FFI or Ethon that names none of this.
29
+ # NaN fails both comparisons, and an infinity one of them.
30
+ def self.checked_timeout(timeout)
31
+ return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout >= 0 && timeout <= LONGEST_TIMEOUT
32
+
33
+ raise ArgumentError,
34
+ "timeout must be a number of seconds from 0 (no bound) to #{LONGEST_TIMEOUT}, not #{timeout.inspect}"
35
+ end
36
+
20
37
  # The generated Configuration applies EVERY security scheme the spec lists,
21
38
  # so a keyless client would send `Authorization: Bearer `, an empty
22
39
  # `X-Api-Key` and an empty `apikey` query parameter. The API answers 401 to
@@ -29,7 +46,7 @@ module VPNDetection
29
46
  self.host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
30
47
  self.base_path = uri.path
31
48
  self.access_token = api_key
32
- self.timeout = timeout
49
+ self.timeout = timeout.nil? ? nil : Transport.checked_timeout(timeout)
33
50
  end
34
51
 
35
52
  def auth_settings
@@ -59,7 +76,7 @@ module VPNDetection
59
76
  def build_request(http_method, path, opts = {})
60
77
  request = super
61
78
  request.options[:followlocation] = false
62
- request.options[:timeout] = opts[:timeout] unless opts[:timeout].nil?
79
+ request.options[:timeout] = Transport.checked_timeout(opts[:timeout]) unless opts[:timeout].nil?
63
80
  request
64
81
  end
65
82
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VPNDetection
4
- VERSION = '5.4.1'
4
+ VERSION = '5.4.3'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vpndetection
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.1
4
+ version: 5.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mslm Dev