ipregistry 1.0.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +201 -0
- data/README.md +285 -0
- data/lib/ipregistry/batch_response.rb +100 -0
- data/lib/ipregistry/cache.rb +100 -0
- data/lib/ipregistry/client.rb +424 -0
- data/lib/ipregistry/errors.rb +122 -0
- data/lib/ipregistry/models/base.rb +83 -0
- data/lib/ipregistry/models/ip_info.rb +111 -0
- data/lib/ipregistry/models/location.rb +69 -0
- data/lib/ipregistry/models/user_agent.rb +31 -0
- data/lib/ipregistry/version.rb +7 -0
- data/lib/ipregistry.rb +33 -0
- metadata +62 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ipregistry
|
|
4
|
+
# Caching backends used by {Client} to memoize IP lookups.
|
|
5
|
+
#
|
|
6
|
+
# Only successful single and batch IP lookups are cached. Origin lookups are
|
|
7
|
+
# never cached, because the requester IP is only known from the response.
|
|
8
|
+
#
|
|
9
|
+
# A cache is any object responding to +get(key)+, +set(key, value)+,
|
|
10
|
+
# +delete(key)+, and +clear+, and safe for concurrent use from multiple
|
|
11
|
+
# threads — bring your own backend (Redis, Rails.cache, ...) by satisfying
|
|
12
|
+
# that interface.
|
|
13
|
+
module Cache
|
|
14
|
+
# The default cache: stores nothing, so lookups always hit the API and
|
|
15
|
+
# data is never stale.
|
|
16
|
+
class None
|
|
17
|
+
def get(_key) = nil
|
|
18
|
+
|
|
19
|
+
def set(_key, _value) = nil
|
|
20
|
+
|
|
21
|
+
def delete(_key) = nil
|
|
22
|
+
|
|
23
|
+
def clear = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A thread-safe, in-process cache with time-based expiration and a bounded
|
|
27
|
+
# size using least-recently-used eviction.
|
|
28
|
+
#
|
|
29
|
+
# cache = Ipregistry::Cache::Memory.new(max_size: 8192, ttl: 600)
|
|
30
|
+
# client = Ipregistry::Client.new("YOUR_API_KEY", cache: cache)
|
|
31
|
+
class Memory
|
|
32
|
+
DEFAULT_MAX_SIZE = 4096
|
|
33
|
+
DEFAULT_TTL = 600 # seconds
|
|
34
|
+
|
|
35
|
+
# @param max_size [Integer] maximum number of entries held before the
|
|
36
|
+
# least recently used entry is evicted (default 4096)
|
|
37
|
+
# @param ttl [Numeric] how long, in seconds, an entry stays valid after
|
|
38
|
+
# being written (default 600)
|
|
39
|
+
# @param clock [#call] overridable monotonic clock, for testing
|
|
40
|
+
def initialize(max_size: DEFAULT_MAX_SIZE, ttl: DEFAULT_TTL,
|
|
41
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
42
|
+
raise ArgumentError, "max_size must be positive" unless max_size.positive?
|
|
43
|
+
raise ArgumentError, "ttl must be positive" unless ttl.positive?
|
|
44
|
+
|
|
45
|
+
@max_size = max_size
|
|
46
|
+
@ttl = ttl
|
|
47
|
+
@clock = clock
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@entries = {} # key => [value, expires_at]; insertion order tracks recency
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the cached value for key, or nil when absent or expired.
|
|
53
|
+
def get(key)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
value, expires_at = @entries[key]
|
|
56
|
+
next nil unless expires_at
|
|
57
|
+
|
|
58
|
+
if @clock.call > expires_at
|
|
59
|
+
@entries.delete(key)
|
|
60
|
+
next nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Re-insert to mark the entry as most recently used.
|
|
64
|
+
@entries.delete(key)
|
|
65
|
+
@entries[key] = [value, expires_at]
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Stores value under key, refreshing its expiration and evicting the
|
|
71
|
+
# least recently used entry if the cache is full.
|
|
72
|
+
def set(key, value)
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@entries.delete(key)
|
|
75
|
+
@entries[key] = [value, @clock.call + @ttl]
|
|
76
|
+
@entries.delete(@entries.each_key.first) while @entries.size > @max_size
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Removes the entry for key, if present.
|
|
82
|
+
def delete(key)
|
|
83
|
+
@mutex.synchronize { @entries.delete(key) }
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Removes every entry.
|
|
88
|
+
def clear
|
|
89
|
+
@mutex.synchronize { @entries.clear }
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Current number of entries, including possibly stale ones. Primarily
|
|
94
|
+
# useful in tests.
|
|
95
|
+
def size
|
|
96
|
+
@mutex.synchronize { @entries.size }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Ipregistry
|
|
9
|
+
# Client for the Ipregistry API. A Client is safe for concurrent use by
|
|
10
|
+
# multiple threads.
|
|
11
|
+
#
|
|
12
|
+
# client = Ipregistry::Client.new("YOUR_API_KEY")
|
|
13
|
+
# info = client.lookup("8.8.8.8")
|
|
14
|
+
# info.location.country.name # => "United States"
|
|
15
|
+
#
|
|
16
|
+
# By default the client uses a 15-second timeout, retries transient failures
|
|
17
|
+
# up to three times, and performs no caching. Behavior is customized with
|
|
18
|
+
# keyword arguments to {#initialize}.
|
|
19
|
+
class Client
|
|
20
|
+
# Base URL of the Ipregistry API used unless overridden with +base_url:+.
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.ipregistry.co"
|
|
22
|
+
|
|
23
|
+
# Maximum number of IP addresses Ipregistry accepts in a single batch
|
|
24
|
+
# request. {#batch_lookup} transparently splits larger arrays into several
|
|
25
|
+
# requests so callers never have to.
|
|
26
|
+
MAX_BATCH_SIZE = 1024
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT = 15 # seconds
|
|
29
|
+
DEFAULT_MAX_RETRIES = 3
|
|
30
|
+
DEFAULT_RETRY_INTERVAL = 1.0 # seconds
|
|
31
|
+
DEFAULT_BATCH_CONCURRENCY = 4
|
|
32
|
+
|
|
33
|
+
# Default value of the User-Agent header sent with requests.
|
|
34
|
+
USER_AGENT = "IpregistryClient/Ruby/#{VERSION}".freeze
|
|
35
|
+
|
|
36
|
+
# Errors raised by Net::HTTP when a request does not complete in time.
|
|
37
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
|
|
38
|
+
private_constant :TIMEOUT_ERRORS
|
|
39
|
+
|
|
40
|
+
# Errors raised by Net::HTTP for connection-level failures.
|
|
41
|
+
CONNECTION_ERRORS = [SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError].freeze
|
|
42
|
+
private_constant :CONNECTION_ERRORS
|
|
43
|
+
|
|
44
|
+
# @return [Object] the cache used by the client
|
|
45
|
+
attr_reader :cache
|
|
46
|
+
|
|
47
|
+
# @return [String] the API base URL
|
|
48
|
+
attr_reader :base_url
|
|
49
|
+
|
|
50
|
+
# Creates a client authenticating with the given API key. You can obtain
|
|
51
|
+
# a key, along with a generous free tier, at https://ipregistry.co.
|
|
52
|
+
#
|
|
53
|
+
# @param api_key [String] your Ipregistry API key
|
|
54
|
+
# @param base_url [String] overrides the API base URL, mainly useful for
|
|
55
|
+
# testing or pointing at a private deployment
|
|
56
|
+
# @param timeout [Numeric] per-request timeout in seconds, applied to
|
|
57
|
+
# connection open, read, and write (default 15)
|
|
58
|
+
# @param max_retries [Integer] maximum number of automatic retries
|
|
59
|
+
# performed in addition to the initial attempt; 0 disables retries
|
|
60
|
+
# (default 3)
|
|
61
|
+
# @param retry_interval [Numeric] base backoff in seconds between retries;
|
|
62
|
+
# successive retries use an exponentially increasing delay
|
|
63
|
+
# (interval * 2^attempt), and a 429 response carrying a Retry-After
|
|
64
|
+
# header takes precedence (default 1)
|
|
65
|
+
# @param retry_on_server_error [Boolean] whether 5xx responses are retried
|
|
66
|
+
# (default true); transient network errors are always retried
|
|
67
|
+
# @param retry_on_too_many_requests [Boolean] whether 429 Too Many
|
|
68
|
+
# Requests responses are retried, honoring the Retry-After header when
|
|
69
|
+
# present. Ipregistry does not rate limit by default (it is opt-in per
|
|
70
|
+
# API key), so this defaults to false
|
|
71
|
+
# @param cache [Object] response cache; disabled by default so that data
|
|
72
|
+
# is never stale. Pass an {Cache::Memory} instance or any object with
|
|
73
|
+
# the same interface
|
|
74
|
+
# @param max_batch_size [Integer] maximum number of IP addresses sent in a
|
|
75
|
+
# single batch request, capped at {MAX_BATCH_SIZE} (the API limit)
|
|
76
|
+
# @param batch_concurrency [Integer] how many batch sub-requests
|
|
77
|
+
# {#batch_lookup} dispatches concurrently when an array is large enough
|
|
78
|
+
# to be split into chunks; 1 means strictly sequential dispatch, which
|
|
79
|
+
# is gentler on a rate-limited API key (default 4)
|
|
80
|
+
# @param user_agent [String] overrides the User-Agent header sent with
|
|
81
|
+
# requests
|
|
82
|
+
def initialize(api_key,
|
|
83
|
+
base_url: DEFAULT_BASE_URL,
|
|
84
|
+
timeout: DEFAULT_TIMEOUT,
|
|
85
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
86
|
+
retry_interval: DEFAULT_RETRY_INTERVAL,
|
|
87
|
+
retry_on_server_error: true,
|
|
88
|
+
retry_on_too_many_requests: false,
|
|
89
|
+
cache: Cache::None.new,
|
|
90
|
+
max_batch_size: MAX_BATCH_SIZE,
|
|
91
|
+
batch_concurrency: DEFAULT_BATCH_CONCURRENCY,
|
|
92
|
+
user_agent: USER_AGENT)
|
|
93
|
+
raise ArgumentError, "api_key must not be nil or empty" if api_key.nil? || api_key.to_s.strip.empty?
|
|
94
|
+
|
|
95
|
+
@api_key = api_key.to_s
|
|
96
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
97
|
+
@timeout = timeout
|
|
98
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
99
|
+
@retry_interval = retry_interval
|
|
100
|
+
@retry_on_server_error = retry_on_server_error
|
|
101
|
+
@retry_on_too_many_requests = retry_on_too_many_requests
|
|
102
|
+
@cache = cache || Cache::None.new
|
|
103
|
+
@max_batch_size = max_batch_size.to_i.clamp(1, MAX_BATCH_SIZE)
|
|
104
|
+
@batch_concurrency = [batch_concurrency.to_i, 1].max
|
|
105
|
+
@user_agent = user_agent
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the data associated with the given IP address. The ip argument
|
|
109
|
+
# must be a non-empty IPv4 or IPv6 address (String or IPAddr); to look up
|
|
110
|
+
# the requester's own IP, use {#lookup_origin} instead.
|
|
111
|
+
#
|
|
112
|
+
# When a cache is configured, a hit is returned without contacting the
|
|
113
|
+
# API.
|
|
114
|
+
#
|
|
115
|
+
# @param ip [String, IPAddr] the IP address to look up
|
|
116
|
+
# @param fields [String, nil] restricts the response to the given fields,
|
|
117
|
+
# using Ipregistry's field selector syntax (for example
|
|
118
|
+
# "location.country.name,security"). This reduces payload size and, in
|
|
119
|
+
# some cases, credit usage. See
|
|
120
|
+
# https://ipregistry.co/docs/filtering-selecting-fields
|
|
121
|
+
# @param hostname [Boolean, nil] enables reverse-DNS hostname resolution
|
|
122
|
+
# (disabled by default)
|
|
123
|
+
# @param params [Hash] arbitrary extra query parameters not covered by a
|
|
124
|
+
# dedicated keyword
|
|
125
|
+
# @return [Models::IpInfo]
|
|
126
|
+
# @raise [ApiError, ClientError]
|
|
127
|
+
def lookup(ip, fields: nil, hostname: nil, **params)
|
|
128
|
+
ip = ip.to_s.strip
|
|
129
|
+
raise ArgumentError, "ip must not be empty; use #lookup_origin for the requester IP" if ip.empty?
|
|
130
|
+
|
|
131
|
+
query = build_query(fields: fields, hostname: hostname, **params)
|
|
132
|
+
key = cache_key(ip, query)
|
|
133
|
+
cached = @cache.get(key)
|
|
134
|
+
return cached if cached
|
|
135
|
+
|
|
136
|
+
info = Models::IpInfo.new(request_json(:get, url_for(ip, query)))
|
|
137
|
+
@cache.set(key, info)
|
|
138
|
+
info
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns the data associated with the IP address the request originates
|
|
142
|
+
# from, enriched with parsed User-Agent data. Origin lookups are never
|
|
143
|
+
# cached, because the requester IP is only known from the response.
|
|
144
|
+
#
|
|
145
|
+
# Accepts the same keywords as {#lookup}.
|
|
146
|
+
#
|
|
147
|
+
# @return [Models::RequesterIpInfo]
|
|
148
|
+
# @raise [ApiError, ClientError]
|
|
149
|
+
def lookup_origin(fields: nil, hostname: nil, **params)
|
|
150
|
+
query = build_query(fields: fields, hostname: hostname, **params)
|
|
151
|
+
Models::RequesterIpInfo.new(request_json(:get, url_for("", query)))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Resolves several IP addresses at once. The returned {BatchResponse}
|
|
155
|
+
# preserves the order of ips, and each entry may independently succeed or
|
|
156
|
+
# fail; a raised error indicates the whole request failed (for example
|
|
157
|
+
# authentication or a network error), not the failure of an individual
|
|
158
|
+
# entry.
|
|
159
|
+
#
|
|
160
|
+
# The Ipregistry API accepts up to {MAX_BATCH_SIZE} addresses per request;
|
|
161
|
+
# larger arrays are transparently split into several requests dispatched
|
|
162
|
+
# with bounded concurrency (see +max_batch_size:+ and
|
|
163
|
+
# +batch_concurrency:+) and reassembled in input order.
|
|
164
|
+
#
|
|
165
|
+
# Entries already present in the cache are served locally; only the
|
|
166
|
+
# remainder are requested from the API, and freshly resolved entries are
|
|
167
|
+
# cached. Accepts the same keywords as {#lookup}.
|
|
168
|
+
#
|
|
169
|
+
# @param ips [Array<String, IPAddr>] the IP addresses to look up
|
|
170
|
+
# @return [BatchResponse]
|
|
171
|
+
# @raise [ApiError, ClientError]
|
|
172
|
+
def batch_lookup(ips, fields: nil, hostname: nil, **params)
|
|
173
|
+
ips = Array(ips).map { |ip| ip.to_s.strip }
|
|
174
|
+
query = build_query(fields: fields, hostname: hostname, **params)
|
|
175
|
+
|
|
176
|
+
cached = ips.map { |ip| @cache.get(cache_key(ip, query)) }
|
|
177
|
+
misses = ips.zip(cached).reject { |_, hit| hit }.map(&:first)
|
|
178
|
+
fresh = resolve_misses(misses, query)
|
|
179
|
+
|
|
180
|
+
BatchResponse.new(assemble_batch_results(ips, cached, fresh, query))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Parses one or more raw User-Agent strings (such as the User-Agent
|
|
184
|
+
# header of an incoming HTTP request) into structured data. Results
|
|
185
|
+
# preserve the order of the input.
|
|
186
|
+
#
|
|
187
|
+
# response = client.parse_user_agents("Mozilla/5.0 ...")
|
|
188
|
+
# response[0].value!.name # => "Chrome"
|
|
189
|
+
#
|
|
190
|
+
# @param user_agents [Array<String>] the User-Agent strings to parse
|
|
191
|
+
# @return [BatchResponse]
|
|
192
|
+
# @raise [ApiError, ClientError]
|
|
193
|
+
def parse_user_agents(*user_agents)
|
|
194
|
+
body = JSON.generate(user_agents.flatten.map(&:to_s))
|
|
195
|
+
data = request_json(:post, "#{@base_url}/user_agent", body: body)
|
|
196
|
+
BatchResponse.new(parse_batch_results(data) { |entry| Models::UserAgent.new(entry) })
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
# Merges cached entries and freshly resolved results back into input
|
|
202
|
+
# order, caching newly successful entries along the way.
|
|
203
|
+
def assemble_batch_results(ips, cached, fresh, query)
|
|
204
|
+
next_fresh = -1
|
|
205
|
+
ips.each_with_index.map do |ip, i|
|
|
206
|
+
next BatchResponse::Result.success(cached[i]) if cached[i]
|
|
207
|
+
|
|
208
|
+
result = fresh[next_fresh += 1]
|
|
209
|
+
# Defensive: the API returned fewer results than requested.
|
|
210
|
+
result ||= BatchResponse::Result.failure(ApiError.new("missing result for requested IP address"))
|
|
211
|
+
@cache.set(cache_key(ip, query), result.value) if result.success?
|
|
212
|
+
result
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Fetches fresh data for the cache-missed IP addresses, splitting them
|
|
217
|
+
# into API-sized chunks dispatched with bounded concurrency when needed.
|
|
218
|
+
# The returned results preserve the order of misses.
|
|
219
|
+
def resolve_misses(misses, query)
|
|
220
|
+
return [] if misses.empty?
|
|
221
|
+
|
|
222
|
+
chunks = misses.each_slice(@max_batch_size).to_a
|
|
223
|
+
return batch_request(chunks.first, query) if chunks.size == 1
|
|
224
|
+
|
|
225
|
+
resolve_chunks(chunks, query).flatten(1)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Dispatches batch chunks with at most batch_concurrency requests in
|
|
229
|
+
# flight and returns their results in order. If any chunk fails, the
|
|
230
|
+
# first error is raised once all in-flight requests have finished, and
|
|
231
|
+
# queued chunks are not started.
|
|
232
|
+
def resolve_chunks(chunks, query)
|
|
233
|
+
chunk_results = Array.new(chunks.size)
|
|
234
|
+
jobs = Queue.new
|
|
235
|
+
chunks.each_with_index { |chunk, index| jobs << [chunk, index] }
|
|
236
|
+
jobs.close
|
|
237
|
+
|
|
238
|
+
mutex = Mutex.new
|
|
239
|
+
first_error = nil
|
|
240
|
+
|
|
241
|
+
workers = Array.new([@batch_concurrency, chunks.size].min) do
|
|
242
|
+
Thread.new do
|
|
243
|
+
while (job = jobs.pop)
|
|
244
|
+
break if mutex.synchronize { first_error }
|
|
245
|
+
|
|
246
|
+
chunk, index = job
|
|
247
|
+
begin
|
|
248
|
+
chunk_results[index] = batch_request(chunk, query)
|
|
249
|
+
rescue Error => e
|
|
250
|
+
mutex.synchronize { first_error ||= e }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
workers.each(&:join)
|
|
256
|
+
|
|
257
|
+
raise first_error if first_error
|
|
258
|
+
|
|
259
|
+
chunk_results
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Performs a single POST batch request and returns per-entry results.
|
|
263
|
+
def batch_request(ips, query)
|
|
264
|
+
data = request_json(:post, url_for("", query), body: JSON.generate(ips))
|
|
265
|
+
parse_batch_results(data) { |entry| Models::IpInfo.new(entry) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Maps the {"results" => [...]} envelope of a batch response to an array
|
|
269
|
+
# of Results, treating elements carrying an error code as failures.
|
|
270
|
+
def parse_batch_results(data)
|
|
271
|
+
entries = data.is_a?(Hash) && data["results"].is_a?(Array) ? data["results"] : []
|
|
272
|
+
entries.map do |entry|
|
|
273
|
+
if entry.is_a?(Hash) && entry["code"]
|
|
274
|
+
BatchResponse::Result.failure(ApiError.from_payload(entry))
|
|
275
|
+
else
|
|
276
|
+
BatchResponse::Result.success(yield(entry))
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Collapses lookup keywords into a deterministic, sorted query hash.
|
|
282
|
+
def build_query(fields:, hostname:, **params)
|
|
283
|
+
query = params.transform_keys(&:to_s)
|
|
284
|
+
query["fields"] = fields unless fields.nil?
|
|
285
|
+
query["hostname"] = hostname unless hostname.nil?
|
|
286
|
+
query.transform_values!(&:to_s)
|
|
287
|
+
query.sort.to_h
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def url_for(ip, query)
|
|
291
|
+
url = "#{@base_url}/#{ip}"
|
|
292
|
+
url += "?#{URI.encode_www_form(query)}" unless query.empty?
|
|
293
|
+
url
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Derives a deterministic cache key from an IP address and its query
|
|
297
|
+
# parameters (already sorted by build_query).
|
|
298
|
+
def cache_key(ip, query)
|
|
299
|
+
return ip if query.empty?
|
|
300
|
+
|
|
301
|
+
"#{ip};#{URI.encode_www_form(query)}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Performs an HTTP request with automatic retries and returns the decoded
|
|
305
|
+
# 2xx response body. Non-2xx responses raise an {ApiError} subclass;
|
|
306
|
+
# transport and decoding failures raise a {ClientError} subclass.
|
|
307
|
+
def request_json(http_method, url, body: nil)
|
|
308
|
+
attempt = 0
|
|
309
|
+
loop do
|
|
310
|
+
response = attempt_request(http_method, url, body, attempt)
|
|
311
|
+
|
|
312
|
+
if response
|
|
313
|
+
status = response.code.to_i
|
|
314
|
+
return parse_json(response.body.to_s) if status.between?(200, 299)
|
|
315
|
+
raise api_error(response, status) unless retry_status?(status) && attempt < @max_retries
|
|
316
|
+
|
|
317
|
+
wait(backoff_delay(attempt, retry_after(response)))
|
|
318
|
+
else
|
|
319
|
+
# A transport error was swallowed by attempt_request: retry.
|
|
320
|
+
wait(backoff_delay(attempt, nil))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
attempt += 1
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Performs one HTTP attempt. Transport errors are re-raised as typed
|
|
328
|
+
# ClientError subclasses once retries are exhausted, and otherwise
|
|
329
|
+
# swallowed (returning nil) so the caller backs off and retries;
|
|
330
|
+
# transient network errors are always retried, regardless of the
|
|
331
|
+
# retry-on-status flags.
|
|
332
|
+
def attempt_request(http_method, url, body, attempt)
|
|
333
|
+
perform(http_method, url, body)
|
|
334
|
+
rescue *TIMEOUT_ERRORS
|
|
335
|
+
raise Ipregistry::TimeoutError, "request timed out after #{@timeout}s" if attempt >= @max_retries
|
|
336
|
+
|
|
337
|
+
nil
|
|
338
|
+
rescue *CONNECTION_ERRORS => e
|
|
339
|
+
raise Ipregistry::ConnectionError, "request failed: #{e.message}" if attempt >= @max_retries
|
|
340
|
+
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def perform(http_method, url, body)
|
|
345
|
+
uri = URI.parse(url)
|
|
346
|
+
request = build_request(http_method, uri, body)
|
|
347
|
+
|
|
348
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
349
|
+
use_ssl: uri.scheme == "https",
|
|
350
|
+
open_timeout: @timeout,
|
|
351
|
+
read_timeout: @timeout,
|
|
352
|
+
write_timeout: @timeout) do |http|
|
|
353
|
+
http.request(request)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def build_request(http_method, uri, body)
|
|
358
|
+
request = case http_method
|
|
359
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
360
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
361
|
+
else raise ArgumentError, "unsupported HTTP method: #{http_method}"
|
|
362
|
+
end
|
|
363
|
+
request["Authorization"] = "ApiKey #{@api_key}"
|
|
364
|
+
request["User-Agent"] = @user_agent
|
|
365
|
+
request["Accept"] = "application/json"
|
|
366
|
+
if body
|
|
367
|
+
request["Content-Type"] = "application/json"
|
|
368
|
+
request.body = body
|
|
369
|
+
end
|
|
370
|
+
request
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def parse_json(body)
|
|
374
|
+
JSON.parse(body)
|
|
375
|
+
rescue JSON::ParserError
|
|
376
|
+
raise ParseError, "failed to decode response body as JSON"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Converts a non-2xx response into the most specific ApiError, falling
|
|
380
|
+
# back to a generic message when the body is not a recognizable error
|
|
381
|
+
# payload.
|
|
382
|
+
def api_error(response, status)
|
|
383
|
+
payload = begin
|
|
384
|
+
JSON.parse(response.body.to_s)
|
|
385
|
+
rescue JSON::ParserError
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
if payload.is_a?(Hash) && payload["code"]
|
|
390
|
+
ApiError.from_payload(payload, http_status: status)
|
|
391
|
+
else
|
|
392
|
+
ApiError.new("unexpected HTTP status #{status}", http_status: status)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def retry_status?(status)
|
|
397
|
+
return @retry_on_too_many_requests if status == 429
|
|
398
|
+
|
|
399
|
+
status.between?(500, 599) && @retry_on_server_error
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Delay before the next retry attempt, honoring an explicit Retry-After
|
|
403
|
+
# duration when positive and otherwise using exponential backoff.
|
|
404
|
+
def backoff_delay(attempt, retry_after)
|
|
405
|
+
return retry_after if retry_after&.positive?
|
|
406
|
+
|
|
407
|
+
@retry_interval * (2**[attempt, 30].min)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Parses a Retry-After header expressed as an integer number of seconds.
|
|
411
|
+
# Returns nil when the header is absent or not a valid non-negative
|
|
412
|
+
# integer (the HTTP-date form is not supported).
|
|
413
|
+
def retry_after(response)
|
|
414
|
+
value = response["Retry-After"]
|
|
415
|
+
return nil unless value&.match?(/\A\d+\z/)
|
|
416
|
+
|
|
417
|
+
Integer(value, 10)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def wait(seconds)
|
|
421
|
+
sleep(seconds) if seconds.positive?
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ipregistry
|
|
4
|
+
# Base class for every error raised by this library. Rescue it to handle any
|
|
5
|
+
# Ipregistry failure with a single clause:
|
|
6
|
+
#
|
|
7
|
+
# begin
|
|
8
|
+
# client.lookup("8.8.8.8")
|
|
9
|
+
# rescue Ipregistry::Error => e
|
|
10
|
+
# # ...
|
|
11
|
+
# end
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised for failures that occur on the client side rather than being
|
|
15
|
+
# reported by the API, such as network errors or a response that cannot be
|
|
16
|
+
# decoded. The underlying cause, when any, is available through #cause.
|
|
17
|
+
class ClientError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a connection to the API cannot be established or is
|
|
20
|
+
# interrupted (DNS failure, connection refused, TLS error, ...).
|
|
21
|
+
class ConnectionError < ClientError; end
|
|
22
|
+
|
|
23
|
+
# Raised when a request does not complete within the configured timeout.
|
|
24
|
+
class TimeoutError < ClientError; end
|
|
25
|
+
|
|
26
|
+
# Raised when a response body cannot be decoded as JSON.
|
|
27
|
+
class ParseError < ClientError; end
|
|
28
|
+
|
|
29
|
+
# Raised when the Ipregistry API reports a failure, such as an invalid IP
|
|
30
|
+
# address, an exhausted credit balance, or throttling.
|
|
31
|
+
#
|
|
32
|
+
# Each documented error code maps to a dedicated subclass (for example
|
|
33
|
+
# +INVALID_API_KEY+ raises {InvalidApiKeyError}), so callers can rescue the
|
|
34
|
+
# exact condition they care about. The raw code, the suggested resolution,
|
|
35
|
+
# and the HTTP status remain available on every instance.
|
|
36
|
+
#
|
|
37
|
+
# In batch lookups, an ApiError may also describe the failure of a single
|
|
38
|
+
# entry rather than the whole request (see {BatchResponse}); such per-entry
|
|
39
|
+
# errors are returned, not raised.
|
|
40
|
+
#
|
|
41
|
+
# The full list of error codes is documented at
|
|
42
|
+
# https://ipregistry.co/docs/errors
|
|
43
|
+
class ApiError < Error
|
|
44
|
+
# @return [String, nil] the raw error code returned by the API
|
|
45
|
+
attr_reader :code
|
|
46
|
+
|
|
47
|
+
# @return [String, nil] a suggestion on how to resolve the error
|
|
48
|
+
attr_reader :resolution
|
|
49
|
+
|
|
50
|
+
# @return [Integer, nil] the HTTP status of the response, when known
|
|
51
|
+
attr_reader :http_status
|
|
52
|
+
|
|
53
|
+
def initialize(message = "API error", code: nil, resolution: nil, http_status: nil)
|
|
54
|
+
@code = code
|
|
55
|
+
@resolution = resolution
|
|
56
|
+
@http_status = http_status
|
|
57
|
+
super(message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds the most specific error for a decoded API error payload.
|
|
61
|
+
#
|
|
62
|
+
# @param payload [Hash] the decoded error body ("code", "message", "resolution")
|
|
63
|
+
# @param http_status [Integer, nil] the HTTP status of the response
|
|
64
|
+
# @return [ApiError] an instance of the subclass matching the error code,
|
|
65
|
+
# or of ApiError itself when the code is not recognized
|
|
66
|
+
def self.from_payload(payload, http_status: nil)
|
|
67
|
+
payload = {} unless payload.is_a?(Hash)
|
|
68
|
+
code = payload["code"]
|
|
69
|
+
klass = ERRORS_BY_CODE.fetch(code, ApiError)
|
|
70
|
+
klass.new(
|
|
71
|
+
payload["message"] || "API error",
|
|
72
|
+
code: code,
|
|
73
|
+
resolution: payload["resolution"],
|
|
74
|
+
http_status: http_status
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class BadRequestError < ApiError; end
|
|
80
|
+
class DisabledApiKeyError < ApiError; end
|
|
81
|
+
class ForbiddenIpError < ApiError; end
|
|
82
|
+
class ForbiddenOriginError < ApiError; end
|
|
83
|
+
class ForbiddenIpOriginError < ApiError; end
|
|
84
|
+
class InternalError < ApiError; end
|
|
85
|
+
class InsufficientCreditsError < ApiError; end
|
|
86
|
+
class InvalidApiKeyError < ApiError; end
|
|
87
|
+
class InvalidAsnError < ApiError; end
|
|
88
|
+
class InvalidFilterSyntaxError < ApiError; end
|
|
89
|
+
class InvalidIpAddressError < ApiError; end
|
|
90
|
+
class MissingApiKeyError < ApiError; end
|
|
91
|
+
class ReservedAsnError < ApiError; end
|
|
92
|
+
class ReservedIpAddressError < ApiError; end
|
|
93
|
+
class TooManyAsnsError < ApiError; end
|
|
94
|
+
class TooManyIpsError < ApiError; end
|
|
95
|
+
class TooManyRequestsError < ApiError; end
|
|
96
|
+
class TooManyUserAgentsError < ApiError; end
|
|
97
|
+
class UnknownAsnError < ApiError; end
|
|
98
|
+
|
|
99
|
+
# Maps raw Ipregistry API error codes to their dedicated error class. See
|
|
100
|
+
# https://ipregistry.co/docs/errors for the authoritative list.
|
|
101
|
+
ERRORS_BY_CODE = {
|
|
102
|
+
"BAD_REQUEST" => BadRequestError,
|
|
103
|
+
"DISABLED_API_KEY" => DisabledApiKeyError,
|
|
104
|
+
"FORBIDDEN_IP" => ForbiddenIpError,
|
|
105
|
+
"FORBIDDEN_ORIGIN" => ForbiddenOriginError,
|
|
106
|
+
"FORBIDDEN_IP_ORIGIN" => ForbiddenIpOriginError,
|
|
107
|
+
"INTERNAL" => InternalError,
|
|
108
|
+
"INSUFFICIENT_CREDITS" => InsufficientCreditsError,
|
|
109
|
+
"INVALID_API_KEY" => InvalidApiKeyError,
|
|
110
|
+
"INVALID_ASN" => InvalidAsnError,
|
|
111
|
+
"INVALID_FILTER_SYNTAX" => InvalidFilterSyntaxError,
|
|
112
|
+
"INVALID_IP_ADDRESS" => InvalidIpAddressError,
|
|
113
|
+
"MISSING_API_KEY" => MissingApiKeyError,
|
|
114
|
+
"RESERVED_ASN" => ReservedAsnError,
|
|
115
|
+
"RESERVED_IP_ADDRESS" => ReservedIpAddressError,
|
|
116
|
+
"TOO_MANY_ASNS" => TooManyAsnsError,
|
|
117
|
+
"TOO_MANY_IPS" => TooManyIpsError,
|
|
118
|
+
"TOO_MANY_REQUESTS" => TooManyRequestsError,
|
|
119
|
+
"TOO_MANY_USER_AGENTS" => TooManyUserAgentsError,
|
|
120
|
+
"UNKNOWN_ASN" => UnknownAsnError
|
|
121
|
+
}.freeze
|
|
122
|
+
end
|