tavily 0.1.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 +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/lib/tavily/client.rb +276 -0
- data/lib/tavily/configuration.rb +69 -0
- data/lib/tavily/connection.rb +298 -0
- data/lib/tavily/errors.rb +95 -0
- data/lib/tavily/object.rb +80 -0
- data/lib/tavily/responses.rb +197 -0
- data/lib/tavily/version.rb +5 -0
- data/lib/tavily.rb +78 -0
- data/sig/tavily/client.rbs +107 -0
- data/sig/tavily/configuration.rbs +23 -0
- data/sig/tavily/connection.rbs +12 -0
- data/sig/tavily/errors.rbs +57 -0
- data/sig/tavily/object.rbs +18 -0
- data/sig/tavily/responses.rbs +108 -0
- data/sig/tavily.rbs +19 -0
- metadata +65 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tavily
|
|
4
|
+
# Holds configuration for a {Tavily::Client}. A global instance is available
|
|
5
|
+
# via {Tavily.configuration} / {Tavily.configure}; individual clients may
|
|
6
|
+
# override any value through keyword arguments to {Tavily::Client#initialize}.
|
|
7
|
+
class Configuration
|
|
8
|
+
# Default Tavily REST API base URL.
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.tavily.com"
|
|
10
|
+
# Default read timeout, in seconds.
|
|
11
|
+
DEFAULT_TIMEOUT = 60
|
|
12
|
+
# Default connection-open timeout, in seconds.
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
14
|
+
# Default number of automatic retries for transient failures.
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
# Base delay (seconds) for exponential backoff between retries.
|
|
17
|
+
DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] Tavily API key (e.g. "tvly-...").
|
|
20
|
+
attr_accessor :api_key
|
|
21
|
+
# @return [String] API base URL.
|
|
22
|
+
attr_accessor :base_url
|
|
23
|
+
# @return [Numeric] read timeout in seconds.
|
|
24
|
+
attr_accessor :timeout
|
|
25
|
+
# @return [Numeric] open timeout in seconds.
|
|
26
|
+
attr_accessor :open_timeout
|
|
27
|
+
# @return [Integer] maximum number of retries for transient errors.
|
|
28
|
+
attr_accessor :max_retries
|
|
29
|
+
# @return [Numeric] base delay for exponential backoff, in seconds.
|
|
30
|
+
attr_accessor :retry_base_delay
|
|
31
|
+
# @return [String, nil] proxy URL (e.g. "http://user:pass@host:port").
|
|
32
|
+
attr_accessor :proxy
|
|
33
|
+
# @return [String, nil] path to a PEM CA-certificate bundle. Defaults to
|
|
34
|
+
# ENV["SSL_CERT_FILE"]. Handy on Windows, where some Ruby builds ship
|
|
35
|
+
# without a usable default certificate store.
|
|
36
|
+
attr_accessor :ca_file
|
|
37
|
+
# @return [Logger, nil] optional logger for request/response diagnostics.
|
|
38
|
+
attr_accessor :logger
|
|
39
|
+
# @return [String] User-Agent header sent with every request.
|
|
40
|
+
attr_accessor :user_agent
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@api_key = ENV.fetch("TAVILY_API_KEY", nil)
|
|
44
|
+
@base_url = ENV.fetch("TAVILY_BASE_URL", DEFAULT_BASE_URL)
|
|
45
|
+
@timeout = Float(ENV.fetch("TAVILY_TIMEOUT", DEFAULT_TIMEOUT))
|
|
46
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
47
|
+
@max_retries = Integer(ENV.fetch("TAVILY_MAX_RETRIES", DEFAULT_MAX_RETRIES))
|
|
48
|
+
@retry_base_delay = DEFAULT_RETRY_BASE_DELAY
|
|
49
|
+
@proxy = ENV.fetch("TAVILY_PROXY", nil)
|
|
50
|
+
@ca_file = ENV.fetch("SSL_CERT_FILE", nil)
|
|
51
|
+
@logger = nil
|
|
52
|
+
@user_agent = "tavily-ruby/#{Tavily::VERSION}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Hash] a shallow copy of the configuration as a hash.
|
|
56
|
+
def to_h
|
|
57
|
+
{
|
|
58
|
+
base_url: base_url,
|
|
59
|
+
timeout: timeout,
|
|
60
|
+
open_timeout: open_timeout,
|
|
61
|
+
max_retries: max_retries,
|
|
62
|
+
retry_base_delay: retry_base_delay,
|
|
63
|
+
proxy: proxy,
|
|
64
|
+
ca_file: ca_file,
|
|
65
|
+
user_agent: user_agent
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Tavily
|
|
9
|
+
# Thin HTTP layer over Ruby's standard library {Net::HTTP}. Handles JSON
|
|
10
|
+
# encoding/decoding, authentication, timeouts, automatic retries with
|
|
11
|
+
# exponential backoff, and translation of HTTP errors into {APIError}s.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class Connection
|
|
15
|
+
# HTTP statuses that are safe to retry (transient).
|
|
16
|
+
RETRIABLE_STATUSES = [408, 409, 425, 429, 500, 502, 503, 504].freeze
|
|
17
|
+
|
|
18
|
+
# Network-level exceptions that warrant a retry.
|
|
19
|
+
RETRIABLE_TIMEOUTS = [
|
|
20
|
+
Net::OpenTimeout,
|
|
21
|
+
Net::ReadTimeout,
|
|
22
|
+
defined?(Net::WriteTimeout) ? Net::WriteTimeout : nil
|
|
23
|
+
].compact.freeze
|
|
24
|
+
|
|
25
|
+
RETRIABLE_NETWORK = [
|
|
26
|
+
SocketError,
|
|
27
|
+
SystemCallError,
|
|
28
|
+
IOError,
|
|
29
|
+
OpenSSL::SSL::SSLError,
|
|
30
|
+
EOFError
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# @param config [Configuration]
|
|
34
|
+
def initialize(config)
|
|
35
|
+
@config = config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Issue a POST request with a JSON body.
|
|
39
|
+
# @param path [String] request path (e.g. "/search")
|
|
40
|
+
# @param body [Hash] request body; nil values are removed before sending
|
|
41
|
+
# @return [Hash, Array, String] parsed response body
|
|
42
|
+
def post(path, body = {})
|
|
43
|
+
request(:post, path, body: body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Issue a GET request with query parameters.
|
|
47
|
+
# @param path [String]
|
|
48
|
+
# @param params [Hash]
|
|
49
|
+
# @return [Hash, Array, String] parsed response body
|
|
50
|
+
def get(path, params = {})
|
|
51
|
+
request(:get, path, query: params)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Issue a POST request and yield Server-Sent Events as they arrive.
|
|
55
|
+
# Streaming responses are not retried.
|
|
56
|
+
#
|
|
57
|
+
# @param path [String]
|
|
58
|
+
# @param body [Hash]
|
|
59
|
+
# @yieldparam event [ResearchEvent]
|
|
60
|
+
# @return [nil]
|
|
61
|
+
def stream(path, body = {})
|
|
62
|
+
ensure_api_key!
|
|
63
|
+
uri = build_uri(path, nil)
|
|
64
|
+
http = build_http(uri)
|
|
65
|
+
req = build_request(:post, uri, body)
|
|
66
|
+
req["Accept"] = "text/event-stream"
|
|
67
|
+
|
|
68
|
+
buffer = +""
|
|
69
|
+
begin
|
|
70
|
+
http.start do |conn|
|
|
71
|
+
conn.request(req) do |response|
|
|
72
|
+
status = response.code.to_i
|
|
73
|
+
raise stream_error(response) unless status.between?(200, 299)
|
|
74
|
+
|
|
75
|
+
response.read_body do |chunk|
|
|
76
|
+
buffer << chunk.gsub("\r\n", "\n")
|
|
77
|
+
while (idx = buffer.index("\n\n"))
|
|
78
|
+
raw_event = buffer.slice!(0..(idx + 1))
|
|
79
|
+
event = parse_sse_event(raw_event)
|
|
80
|
+
yield event if event
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
rescue *RETRIABLE_TIMEOUTS => e
|
|
86
|
+
raise TimeoutError, "Tavily stream timed out: #{e.message}"
|
|
87
|
+
rescue *RETRIABLE_NETWORK => e
|
|
88
|
+
raise ConnectionError, "Tavily stream connection failed: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
event = parse_sse_event(buffer)
|
|
92
|
+
yield event if event
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def request(method, path, body: nil, query: nil)
|
|
99
|
+
ensure_api_key!
|
|
100
|
+
uri = build_uri(path, query)
|
|
101
|
+
attempt = 0
|
|
102
|
+
|
|
103
|
+
loop do
|
|
104
|
+
attempt += 1
|
|
105
|
+
response =
|
|
106
|
+
begin
|
|
107
|
+
perform(method, uri, body)
|
|
108
|
+
rescue *RETRIABLE_TIMEOUTS => e
|
|
109
|
+
if attempt > @config.max_retries
|
|
110
|
+
raise TimeoutError,
|
|
111
|
+
"Tavily request timed out after #{@config.timeout}s: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
log { "timeout (attempt #{attempt}), retrying: #{e.message}" }
|
|
115
|
+
sleep(backoff(attempt))
|
|
116
|
+
next
|
|
117
|
+
rescue *RETRIABLE_NETWORK => e
|
|
118
|
+
raise ConnectionError, "Tavily connection failed: #{e.message}" if attempt > @config.max_retries
|
|
119
|
+
|
|
120
|
+
log { "network error (attempt #{attempt}), retrying: #{e.message}" }
|
|
121
|
+
sleep(backoff(attempt))
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
status = response.code.to_i
|
|
126
|
+
if RETRIABLE_STATUSES.include?(status) && attempt <= @config.max_retries
|
|
127
|
+
log { "HTTP #{status} (attempt #{attempt}), retrying" }
|
|
128
|
+
sleep(backoff(attempt, response))
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return handle(response, status)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def perform(method, uri, body)
|
|
137
|
+
http = build_http(uri)
|
|
138
|
+
req = build_request(method, uri, body)
|
|
139
|
+
started = monotonic_now
|
|
140
|
+
response = http.request(req)
|
|
141
|
+
log { "#{method.to_s.upcase} #{uri.path} -> #{response.code} in #{format("%.3f", monotonic_now - started)}s" }
|
|
142
|
+
response
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_http(uri)
|
|
146
|
+
http =
|
|
147
|
+
if @config.proxy && !@config.proxy.empty?
|
|
148
|
+
proxy = URI.parse(@config.proxy)
|
|
149
|
+
Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
150
|
+
else
|
|
151
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
http.use_ssl = uri.scheme == "https"
|
|
155
|
+
http.ca_file = @config.ca_file if http.use_ssl? && @config.ca_file && File.exist?(@config.ca_file)
|
|
156
|
+
http.open_timeout = @config.open_timeout
|
|
157
|
+
http.read_timeout = @config.timeout
|
|
158
|
+
http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
|
|
159
|
+
http
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_request(method, uri, body)
|
|
163
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
164
|
+
req = klass.new(uri)
|
|
165
|
+
req["Authorization"] = "Bearer #{@config.api_key}"
|
|
166
|
+
req["Content-Type"] = "application/json"
|
|
167
|
+
req["Accept"] = "application/json"
|
|
168
|
+
req["User-Agent"] = @config.user_agent
|
|
169
|
+
|
|
170
|
+
payload = compact(body)
|
|
171
|
+
req.body = JSON.generate(payload) if payload && !payload.empty?
|
|
172
|
+
req
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_uri(path, query)
|
|
176
|
+
uri = URI.join("#{@config.base_url.chomp("/")}/", path.to_s.sub(%r{\A/}, ""))
|
|
177
|
+
uri.query = URI.encode_www_form(compact(query)) if query && !query.empty?
|
|
178
|
+
uri
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def handle(response, status)
|
|
182
|
+
parsed = parse_body(response.body)
|
|
183
|
+
return parsed if status.between?(200, 299)
|
|
184
|
+
|
|
185
|
+
raise build_error(status, parsed)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_body(raw)
|
|
189
|
+
return {} if raw.nil? || raw.empty?
|
|
190
|
+
|
|
191
|
+
JSON.parse(raw)
|
|
192
|
+
rescue JSON::ParserError
|
|
193
|
+
raw
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_error(status, parsed)
|
|
197
|
+
request_id = parsed.is_a?(Hash) ? parsed["request_id"] : nil
|
|
198
|
+
message = extract_error_message(parsed)
|
|
199
|
+
Errors.class_for(status).new(message, status: status, body: parsed, request_id: request_id)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Tavily returns errors in a few shapes:
|
|
203
|
+
# {"detail": {"error": "..."}} (auth / bad request)
|
|
204
|
+
# {"detail": [{"loc": [...], "msg": "...", ...}]} (FastAPI validation)
|
|
205
|
+
# {"error": "..."} / {"message": "..."} (fallbacks)
|
|
206
|
+
def extract_error_message(parsed)
|
|
207
|
+
return parsed.to_s unless parsed.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
detail = parsed["detail"] || parsed["error"] || parsed["message"]
|
|
210
|
+
case detail
|
|
211
|
+
when String then detail
|
|
212
|
+
when Hash then detail["error"] || detail["message"] || detail.to_s
|
|
213
|
+
when Array then detail.map { |d| format_detail(d) }.join("; ")
|
|
214
|
+
else parsed.to_s
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def format_detail(entry)
|
|
219
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
loc = Array(entry["loc"]).reject { |part| part == "body" }.join(".")
|
|
222
|
+
msg = entry["msg"] || entry["error"] || entry.to_s
|
|
223
|
+
loc.empty? ? msg : "#{loc}: #{msg}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Remove nil values so callers can pass optional params freely without
|
|
227
|
+
# accidentally sending nulls the API would reject.
|
|
228
|
+
def compact(hash)
|
|
229
|
+
return hash unless hash.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
232
|
+
acc[key] = value unless value.nil?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def backoff(attempt, response = nil)
|
|
237
|
+
if response && (retry_after = response["retry-after"])
|
|
238
|
+
seconds = retry_after.to_f
|
|
239
|
+
return seconds if seconds.positive?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
base = @config.retry_base_delay * (2**(attempt - 1))
|
|
243
|
+
jitter = base * 0.25 * rand
|
|
244
|
+
base + jitter
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def stream_error(response)
|
|
248
|
+
body = +""
|
|
249
|
+
response.read_body { |chunk| body << chunk }
|
|
250
|
+
build_error(response.code.to_i, parse_body(body))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def parse_sse_event(raw)
|
|
254
|
+
event_name = nil
|
|
255
|
+
data_lines = []
|
|
256
|
+
raw.each_line(chomp: true) do |line|
|
|
257
|
+
next if line.empty? || line.start_with?(":")
|
|
258
|
+
|
|
259
|
+
field, _separator, value = line.partition(":")
|
|
260
|
+
value = value.sub(/\A /, "")
|
|
261
|
+
case field
|
|
262
|
+
when "event" then event_name = value
|
|
263
|
+
when "data" then data_lines << value
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
return nil if event_name.nil? && data_lines.empty?
|
|
267
|
+
|
|
268
|
+
ResearchEvent.new(event: event_name, data: parse_sse_data(data_lines.join("\n")))
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def parse_sse_data(raw)
|
|
272
|
+
return nil if raw.empty?
|
|
273
|
+
|
|
274
|
+
JSON.parse(raw)
|
|
275
|
+
rescue JSON::ParserError
|
|
276
|
+
raw
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def ensure_api_key!
|
|
280
|
+
return unless @config.api_key.nil? || @config.api_key.to_s.empty?
|
|
281
|
+
|
|
282
|
+
raise ConfigurationError,
|
|
283
|
+
"Missing Tavily API key. Set ENV['TAVILY_API_KEY'], call " \
|
|
284
|
+
"Tavily.configure { |c| c.api_key = ... }, or pass api_key: to Tavily::Client.new."
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def monotonic_now
|
|
288
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def log
|
|
292
|
+
logger = @config.logger
|
|
293
|
+
return unless logger
|
|
294
|
+
|
|
295
|
+
logger.debug("[tavily] #{yield}")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tavily
|
|
4
|
+
# Base class for every error raised by this library. Rescue this to catch
|
|
5
|
+
# anything the gem might raise.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the client is misconfigured (e.g. a missing API key).
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a request exceeds the configured timeout.
|
|
12
|
+
class TimeoutError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when the connection to the API fails at the network/TLS layer.
|
|
15
|
+
class ConnectionError < Error; end
|
|
16
|
+
|
|
17
|
+
# Base class for errors returned by the Tavily API (non-2xx responses).
|
|
18
|
+
# Carries the HTTP status, parsed body, and request id when available.
|
|
19
|
+
class APIError < Error
|
|
20
|
+
# @return [Integer, nil] HTTP status code.
|
|
21
|
+
attr_reader :status
|
|
22
|
+
# @return [Object, nil] parsed response body (Hash, Array, or String).
|
|
23
|
+
attr_reader :body
|
|
24
|
+
# @return [String, nil] Tavily request id, useful for support tickets.
|
|
25
|
+
attr_reader :request_id
|
|
26
|
+
|
|
27
|
+
# @param message [String, nil] human-readable error message
|
|
28
|
+
# @param status [Integer, nil] HTTP status code
|
|
29
|
+
# @param body [Object, nil] parsed response body
|
|
30
|
+
# @param request_id [String, nil] Tavily request id
|
|
31
|
+
def initialize(message = nil, status: nil, body: nil, request_id: nil)
|
|
32
|
+
@status = status
|
|
33
|
+
@body = body
|
|
34
|
+
@request_id = request_id
|
|
35
|
+
super(build_message(message))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_message(message)
|
|
41
|
+
parts = []
|
|
42
|
+
parts << "[#{status}]" if status
|
|
43
|
+
parts << (message || "Tavily API error")
|
|
44
|
+
parts << "(request_id: #{request_id})" if request_id
|
|
45
|
+
parts.join(" ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# 400 — the request was malformed or a parameter value was invalid.
|
|
50
|
+
class BadRequestError < APIError; end
|
|
51
|
+
# 401 — missing or invalid API key.
|
|
52
|
+
class AuthenticationError < APIError; end
|
|
53
|
+
# 403 — the key is valid but not permitted (plan/usage limit reached).
|
|
54
|
+
class ForbiddenError < APIError; end
|
|
55
|
+
# 404 — the requested resource does not exist.
|
|
56
|
+
class NotFoundError < APIError; end
|
|
57
|
+
# 422 — request body failed validation.
|
|
58
|
+
class UnprocessableEntityError < APIError; end
|
|
59
|
+
# 429 — rate limit exceeded.
|
|
60
|
+
class RateLimitError < APIError; end
|
|
61
|
+
|
|
62
|
+
# Base class for Tavily's non-standard usage/quota limit errors (432, 433).
|
|
63
|
+
class UsageLimitError < APIError; end
|
|
64
|
+
# 432 — plan/key credit quota exceeded (non-standard Tavily status code).
|
|
65
|
+
class PlanLimitError < UsageLimitError; end
|
|
66
|
+
# 433 — pay-as-you-go spending limit exceeded (non-standard Tavily status code).
|
|
67
|
+
class PayAsYouGoLimitError < UsageLimitError; end
|
|
68
|
+
|
|
69
|
+
# 5xx — Tavily server-side error.
|
|
70
|
+
class ServerError < APIError; end
|
|
71
|
+
|
|
72
|
+
# Maps HTTP status codes to {APIError} subclasses.
|
|
73
|
+
module Errors
|
|
74
|
+
# @api private
|
|
75
|
+
STATUS_MAP = {
|
|
76
|
+
400 => BadRequestError,
|
|
77
|
+
401 => AuthenticationError,
|
|
78
|
+
403 => ForbiddenError,
|
|
79
|
+
404 => NotFoundError,
|
|
80
|
+
422 => UnprocessableEntityError,
|
|
81
|
+
429 => RateLimitError,
|
|
82
|
+
432 => PlanLimitError,
|
|
83
|
+
433 => PayAsYouGoLimitError
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
# @param status [Integer] HTTP status code
|
|
87
|
+
# @return [Class] the most specific {APIError} subclass for +status+
|
|
88
|
+
def self.class_for(status)
|
|
89
|
+
return STATUS_MAP[status] if STATUS_MAP.key?(status)
|
|
90
|
+
return ServerError if status >= 500
|
|
91
|
+
|
|
92
|
+
APIError
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tavily
|
|
4
|
+
# Base class for every response object returned by the client. Wraps the
|
|
5
|
+
# parsed JSON hash, exposing typed accessors (declared with {.attribute})
|
|
6
|
+
# while remaining forward-compatible: any field the API adds is still
|
|
7
|
+
# reachable via {#[]}, {#dig}, and {#to_h}.
|
|
8
|
+
class Object
|
|
9
|
+
# @return [Hash] the raw, parsed response body with string keys.
|
|
10
|
+
attr_reader :attributes
|
|
11
|
+
|
|
12
|
+
# Declare a typed accessor for a response field.
|
|
13
|
+
#
|
|
14
|
+
# @param name [Symbol] method name to define
|
|
15
|
+
# @param key [String] the key in the raw hash (defaults to +name+)
|
|
16
|
+
# @param wrap [Class, String, nil] wrap the value in this {Tavily::Object}
|
|
17
|
+
# subclass (may be given as a String to allow forward references)
|
|
18
|
+
# @param collection [Boolean] when true, treat the value as an array and
|
|
19
|
+
# wrap each element
|
|
20
|
+
# @return [void]
|
|
21
|
+
def self.attribute(name, key: name.to_s, wrap: nil, collection: false)
|
|
22
|
+
define_method(name) do
|
|
23
|
+
raw = @attributes[key]
|
|
24
|
+
return raw if wrap.nil?
|
|
25
|
+
|
|
26
|
+
klass = wrap.is_a?(Class) ? wrap : Tavily.const_get(wrap)
|
|
27
|
+
if collection
|
|
28
|
+
Array(raw).map { |item| item.is_a?(Hash) ? klass.new(item) : item }
|
|
29
|
+
elsif raw.is_a?(Hash)
|
|
30
|
+
klass.new(raw)
|
|
31
|
+
else
|
|
32
|
+
raw
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param attributes [Hash] parsed response body
|
|
38
|
+
def initialize(attributes = {})
|
|
39
|
+
@attributes = attributes.is_a?(Hash) ? attributes : {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fetch a raw field by name (String or Symbol).
|
|
43
|
+
# @param key [String, Symbol]
|
|
44
|
+
# @return [Object, nil]
|
|
45
|
+
def [](key)
|
|
46
|
+
@attributes[key.to_s]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @see Hash#dig
|
|
50
|
+
# @return [Object, nil]
|
|
51
|
+
def dig(*keys)
|
|
52
|
+
@attributes.dig(*keys.map(&:to_s))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param key [String, Symbol]
|
|
56
|
+
# @return [Boolean] whether the raw field is present
|
|
57
|
+
def key?(key)
|
|
58
|
+
@attributes.key?(key.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Hash] the raw response body
|
|
62
|
+
def to_h
|
|
63
|
+
@attributes
|
|
64
|
+
end
|
|
65
|
+
alias to_hash to_h
|
|
66
|
+
|
|
67
|
+
def ==(other)
|
|
68
|
+
other.is_a?(self.class) && other.attributes == attributes
|
|
69
|
+
end
|
|
70
|
+
alias eql? ==
|
|
71
|
+
|
|
72
|
+
def hash
|
|
73
|
+
[self.class, @attributes].hash
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def inspect
|
|
77
|
+
"#<#{self.class.name} #{@attributes.inspect}>"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|