synthient 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.
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Synthient
4
+ # Net::HTTP's own retry is disabled on every session, so a stale keep-alive
5
+ # connection never replays a billed lookup. Retries happen only here:
6
+ # :rate_limit retries a 429 that carries Retry-After, and :transient also
7
+ # retries RETRY_STATUSES and failures other than TLS errors before a
8
+ # response arrives.
9
+ #
10
+ # @api private
11
+ class Transport
12
+ MAX_RESPONSE_SIZE = 16 << 20
13
+ MAX_ERROR_SIZE = 64 << 10
14
+ MAX_EVENT_SIZE = 16 << 20
15
+ MAX_IDLE_SESSIONS = 8
16
+ MAX_RETRY_AFTER = 60
17
+ MAX_REDIRECTS = 10
18
+ RETRY_STATUSES = %w[500 502 503 504].freeze
19
+ RETRY = Object.new.freeze
20
+ private_constant :RETRY
21
+ # Net::ProtocolError is what Net::HTTP raises when a proxy refuses CONNECT.
22
+ TRANSPORT_ERRORS = [
23
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
24
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
25
+ ].freeze
26
+
27
+ def self.parse_retry_after(value)
28
+ value = value.to_s.strip
29
+ return nil if value.empty?
30
+ return value.to_i.clamp(0, 2**31) if value.match?(/\A[-+]?\d+\z/)
31
+
32
+ [Time.httpdate(value) - Time.now, 0].max
33
+ rescue ArgumentError
34
+ nil
35
+ end
36
+
37
+ def initialize(base_url:, api_key:, user_agent:, max_retries:, timeout:, open_timeout:, response_hook:)
38
+ @base_url = base_url
39
+ @base_scheme = URI(base_url).scheme
40
+ @api_key = api_key
41
+ @user_agent = user_agent
42
+ @max_retries = max_retries
43
+ @timeout = timeout
44
+ @open_timeout = open_timeout
45
+ @response_hook = response_hook
46
+ @idle = []
47
+ @mutex = Mutex.new
48
+ @pid = Process.pid
49
+ @closed = false
50
+ end
51
+
52
+ def json(method, path, policy:, query: nil, body: nil)
53
+ execute(method, path, policy:, query:, body:) do |response|
54
+ raise_api_error(response) unless response.is_a?(Net::HTTPSuccess)
55
+
56
+ data = "".b
57
+ read_body(response) do |chunk|
58
+ data << chunk
59
+ raise DecodeError, "response body exceeds #{MAX_RESPONSE_SIZE} bytes" if data.bytesize > MAX_RESPONSE_SIZE
60
+ end
61
+ parse_object(data)
62
+ end
63
+ end
64
+
65
+ # Blank keepalive lines are skipped, and a final line without a newline
66
+ # is still decoded.
67
+ def stream(path)
68
+ execute("GET", path, policy: :transient, accept: "application/x-ndjson", dedicated: true) do |response|
69
+ raise_api_error(response) unless response.code == "200"
70
+
71
+ pending = "".b
72
+ read_body(response) do |chunk|
73
+ pending << chunk
74
+ while (newline = pending.index("\n"))
75
+ line = pending.slice!(0, newline + 1)
76
+ raise DecodeError, "stream event exceeds #{MAX_EVENT_SIZE} bytes" if line.bytesize > MAX_EVENT_SIZE
77
+
78
+ line.strip!
79
+ yield parse_object(line) unless line.empty?
80
+ end
81
+ raise DecodeError, "stream event exceeds #{MAX_EVENT_SIZE} bytes" if pending.bytesize > MAX_EVENT_SIZE
82
+ end
83
+ pending.strip!
84
+ yield parse_object(pending) unless pending.empty?
85
+ end
86
+ end
87
+
88
+ def redirect_location(path)
89
+ execute("GET", path, policy: :transient) do |response|
90
+ raise_api_error(response) if response.code.to_i >= 400
91
+ unless response.is_a?(Net::HTTPRedirection)
92
+ raise DecodeError, "expected a redirect to the export, got status #{response.code}"
93
+ end
94
+
95
+ location = resolve_location(response)
96
+ raise DecodeError, "export redirect has no valid location" unless location
97
+ raise DecodeError, "export redirect does not use https" unless ["https", @base_scheme].include?(location.scheme)
98
+
99
+ size = 0
100
+ read_body(response) do |chunk|
101
+ size += chunk.bytesize
102
+ raise DecodeError, "export redirect body exceeds #{MAX_ERROR_SIZE} bytes" if size > MAX_ERROR_SIZE
103
+ end
104
+ location
105
+ end
106
+ end
107
+
108
+ # Never sends the API key to storage. Accept-Encoding: identity yields the
109
+ # bytes as stored.
110
+ def download(uri, &block)
111
+ redirects = 0
112
+ while redirects <= MAX_REDIRECTS
113
+ session = nil
114
+ response = nil
115
+ next_uri = nil
116
+ begin
117
+ session = start_session(uri)
118
+ request = Net::HTTP::Get.new(uri, "User-Agent" => @user_agent, "Accept-Encoding" => "identity")
119
+ session.request(request) do |res|
120
+ response = res
121
+ if res.is_a?(Net::HTTPRedirection)
122
+ next_uri = resolve_location(res) || raise(Error, "export redirect has no valid location")
123
+ break
124
+ end
125
+ raise_api_error(res) unless res.code == "200"
126
+
127
+ read_body(res, &block)
128
+ end
129
+ rescue *TRANSPORT_ERRORS => e
130
+ raise unless response.nil?
131
+
132
+ raise connection_error(e)
133
+ ensure
134
+ discard(session)
135
+ end
136
+ return unless next_uri
137
+ raise Error, "export redirect downgrades to http" if uri.scheme == "https" && next_uri.scheme == "http"
138
+
139
+ redirects += 1
140
+ uri = next_uri
141
+ end
142
+ raise Error, "too many export redirects"
143
+ end
144
+
145
+ def close
146
+ sessions = @mutex.synchronize do
147
+ @closed = true
148
+ @pid == Process.pid ? @idle.slice!(0..) : []
149
+ end
150
+ sessions.each { |session| discard(session) }
151
+ nil
152
+ end
153
+
154
+ def inspect
155
+ "#<#{self.class.name} #{@base_url}>"
156
+ end
157
+
158
+ private
159
+
160
+ def execute(method, path, policy:, query: nil, body: nil, accept: "application/json", dedicated: false, &)
161
+ raise IOError, "client is closed" if @closed
162
+
163
+ uri = URI("#{@base_url}#{path}#{"?#{URI.encode_www_form(query.sort)}" unless query.nil? || query.empty?}")
164
+ (0..@max_retries).each do |attempt|
165
+ delay = catch(RETRY) { return attempt_request(uri, method, body, accept, policy, attempt, dedicated, &) }
166
+ sleep(delay)
167
+ end
168
+ end
169
+
170
+ def attempt_request(uri, method, body, accept, policy, attempt, dedicated)
171
+ response = nil
172
+ reusable = false
173
+ session = dedicated ? start_session(uri) : checkout(uri)
174
+ headers = { "X-Api-Key" => @api_key, "User-Agent" => @user_agent, "Accept" => accept }
175
+ headers["Content-Type"] = "application/json" if body
176
+ result = nil
177
+ session.request(Net::HTTPGenericRequest.new(method, !body.nil?, true, uri, headers), body) do |res|
178
+ response = res
179
+ report(res) if @response_hook
180
+ retryable = if res.code == "429"
181
+ !res["retry-after"].to_s.strip.empty?
182
+ else
183
+ policy == :transient && RETRY_STATUSES.include?(res.code)
184
+ end
185
+ retry_after = Transport.parse_retry_after(res["retry-after"])
186
+ if retryable && attempt < @max_retries && (retry_after.nil? || retry_after <= MAX_RETRY_AFTER)
187
+ throw RETRY, [backoff(attempt), retry_after || 0].max
188
+ end
189
+
190
+ result = yield res
191
+ end
192
+ reusable = !dedicated
193
+ result
194
+ rescue *TRANSPORT_ERRORS => e
195
+ raise unless response.nil?
196
+
197
+ throw RETRY, backoff(attempt) if policy == :transient && attempt < @max_retries && !e.is_a?(OpenSSL::SSL::SSLError)
198
+
199
+ raise connection_error(e)
200
+ ensure
201
+ reusable ? checkin(session) : discard(session)
202
+ end
203
+
204
+ def backoff(attempt)
205
+ [2**[attempt, 5].min, 30].min * (0.75 + (rand / 2))
206
+ end
207
+
208
+ def report(response)
209
+ headers = response.each_header.to_h { |name, value| [name.freeze, value.freeze] }.freeze
210
+ limits = %w[ratelimit-limit ratelimit-remaining ratelimit-reset].map { |name| response[name]&.strip }
211
+ if limits.all? { |value| value&.match?(/\A\d+\z/) }
212
+ limit, remaining, reset = limits.map(&:to_i)
213
+ rate_limit = RateLimit.new(limit:, remaining:, reset: [reset, 2**31].min)
214
+ end
215
+ @response_hook.call(
216
+ ResponseMetadata.new(status: response.code.to_i, request_id: response["x-request-id"], rate_limit:, headers:)
217
+ )
218
+ end
219
+
220
+ # Exceptions from the block pass through unchanged, so an IOError raised
221
+ # by a caller's block is not reported as a ConnectionError.
222
+ def read_body(response)
223
+ in_block = false
224
+ response.read_body do |chunk|
225
+ in_block = true
226
+ yield chunk
227
+ in_block = false
228
+ end
229
+ rescue *TRANSPORT_ERRORS => e
230
+ raise if in_block
231
+
232
+ raise connection_error(e)
233
+ rescue Zlib::Error
234
+ raise if in_block
235
+
236
+ raise DecodeError, "response body has invalid compression"
237
+ end
238
+
239
+ def connection_error(error)
240
+ timeout = error.is_a?(Timeout::Error) || error.is_a?(Errno::ETIMEDOUT)
241
+ (timeout ? TimeoutError : ConnectionError).new(error.message)
242
+ end
243
+
244
+ def parse_object(json)
245
+ object = JSON.parse(json, freeze: true)
246
+ raise DecodeError, "response is not a JSON object" unless object.is_a?(Hash)
247
+
248
+ object
249
+ rescue JSON::ParserError
250
+ raise DecodeError, "response is not valid JSON"
251
+ end
252
+
253
+ def raise_api_error(response)
254
+ status = response.code.to_i
255
+ retry_after = Transport.parse_retry_after(response["retry-after"])
256
+ request_id = response["x-request-id"]
257
+ data = "".b
258
+ begin
259
+ read_body(response) do |chunk|
260
+ data << chunk
261
+ break if data.bytesize >= MAX_ERROR_SIZE
262
+ end
263
+ rescue ConnectionError
264
+ raise APIError.new(status:, request_id:, retry_after:)
265
+ end
266
+
267
+ problem = begin
268
+ JSON.parse(data, freeze: true)
269
+ rescue JSON::ParserError
270
+ nil
271
+ end
272
+ fields = {}
273
+ if problem.is_a?(Hash)
274
+ %w[type title detail].each { |key| fields[key.to_sym] = problem[key] if problem[key].is_a?(String) }
275
+ errors = problem["errors"]
276
+ if errors.is_a?(Hash) && errors.each_value.all? { |messages| messages.is_a?(Array) && messages.all?(String) }
277
+ fields[:errors] = errors
278
+ end
279
+ request_id = problem["instance"] if (request_id.nil? || request_id.empty?) && problem["instance"].is_a?(String)
280
+ elsif response.content_type == "text/plain"
281
+ fields[:detail] = data.force_encoding(Encoding::UTF_8).scrub.strip
282
+ end
283
+ raise APIError.new(status:, request_id:, retry_after:, **fields)
284
+ end
285
+
286
+ def resolve_location(response)
287
+ location = response["location"].to_s.strip
288
+ return nil if location.empty?
289
+
290
+ uri = URI.join(response.uri.to_s, location)
291
+ uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
292
+ rescue URI::Error
293
+ nil
294
+ end
295
+
296
+ def checkout(uri)
297
+ session = @mutex.synchronize do
298
+ if @pid != Process.pid
299
+ @idle.clear
300
+ @pid = Process.pid
301
+ end
302
+ @idle.pop
303
+ end
304
+ session || start_session(uri)
305
+ end
306
+
307
+ def checkin(session)
308
+ kept = @mutex.synchronize do
309
+ next false if @closed || @pid != Process.pid || @idle.size >= MAX_IDLE_SESSIONS
310
+
311
+ @idle.push(session)
312
+ end
313
+ discard(session) unless kept
314
+ end
315
+
316
+ def discard(session)
317
+ session.finish if session&.started?
318
+ rescue IOError
319
+ nil
320
+ end
321
+
322
+ def start_session(uri)
323
+ session = Net::HTTP.new(uri.hostname, uri.port)
324
+ session.use_ssl = uri.scheme == "https"
325
+ session.verify_mode = OpenSSL::SSL::VERIFY_PEER
326
+ session.verify_hostname = true
327
+ session.open_timeout = @open_timeout
328
+ session.read_timeout = @timeout
329
+ session.write_timeout = @timeout
330
+ session.keep_alive_timeout = 2
331
+ session.max_retries = 0
332
+ # A body cut short of its Content-Length raises EOFError instead of
333
+ # reading as complete.
334
+ session.ignore_eof = false
335
+ session.response_body_encoding = false
336
+ session.set_debug_output(nil)
337
+ session.start
338
+ end
339
+ end
340
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Synthient
4
+ VERSION = "1.0.0"
5
+ end
data/lib/synthient.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "ipaddr"
5
+ require "json"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "time"
9
+ require "uri"
10
+ require "zlib"
11
+
12
+ # The official Ruby client for the Synthient API: IP and domain intelligence,
13
+ # account details, and real-time and bulk data feeds. Start with
14
+ # Synthient::Client.
15
+ module Synthient
16
+ DEFAULT_BASE_URL = "https://api.synthient.com/api/v4"
17
+ end
18
+
19
+ require_relative "synthient/version"
20
+ require_relative "synthient/errors"
21
+ require_relative "synthient/response_metadata"
22
+ require_relative "synthient/model"
23
+ require_relative "synthient/models/lookup"
24
+ require_relative "synthient/models/domain"
25
+ require_relative "synthient/models/account"
26
+ require_relative "synthient/models/export"
27
+ require_relative "synthient/models/events"
28
+ require_relative "synthient/transport"
29
+ require_relative "synthient/feed"
30
+ require_relative "synthient/client"
data/sig/manifest.yaml ADDED
@@ -0,0 +1,4 @@
1
+ dependencies:
2
+ - name: date
3
+ - name: ipaddr
4
+ - name: uri