exa-ai-ruby 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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +247 -0
  5. data/lib/exa/client.rb +33 -0
  6. data/lib/exa/errors.rb +34 -0
  7. data/lib/exa/internal/transport/base_client.rb +171 -0
  8. data/lib/exa/internal/transport/pooled_net_requester.rb +113 -0
  9. data/lib/exa/internal/transport/stream.rb +74 -0
  10. data/lib/exa/internal/util.rb +133 -0
  11. data/lib/exa/resources/base.rb +26 -0
  12. data/lib/exa/resources/events.rb +32 -0
  13. data/lib/exa/resources/imports.rb +58 -0
  14. data/lib/exa/resources/research.rb +50 -0
  15. data/lib/exa/resources/search.rb +44 -0
  16. data/lib/exa/resources/webhooks.rb +67 -0
  17. data/lib/exa/resources/websets/enrichments.rb +57 -0
  18. data/lib/exa/resources/websets/items.rb +40 -0
  19. data/lib/exa/resources/websets/monitors.rb +75 -0
  20. data/lib/exa/resources/websets.rb +71 -0
  21. data/lib/exa/resources.rb +9 -0
  22. data/lib/exa/responses/contents_response.rb +35 -0
  23. data/lib/exa/responses/event_response.rb +43 -0
  24. data/lib/exa/responses/helpers.rb +29 -0
  25. data/lib/exa/responses/import_response.rb +90 -0
  26. data/lib/exa/responses/monitor_response.rb +77 -0
  27. data/lib/exa/responses/raw_response.rb +14 -0
  28. data/lib/exa/responses/research_response.rb +56 -0
  29. data/lib/exa/responses/result.rb +61 -0
  30. data/lib/exa/responses/search_response.rb +43 -0
  31. data/lib/exa/responses/webhook_response.rb +95 -0
  32. data/lib/exa/responses/webset_response.rb +136 -0
  33. data/lib/exa/responses.rb +13 -0
  34. data/lib/exa/types/answer.rb +30 -0
  35. data/lib/exa/types/base.rb +66 -0
  36. data/lib/exa/types/contents.rb +25 -0
  37. data/lib/exa/types/enums.rb +47 -0
  38. data/lib/exa/types/find_similar.rb +26 -0
  39. data/lib/exa/types/research.rb +18 -0
  40. data/lib/exa/types/schema.rb +58 -0
  41. data/lib/exa/types/search.rb +74 -0
  42. data/lib/exa/types.rb +10 -0
  43. data/lib/exa/version.rb +5 -0
  44. data/lib/exa.rb +14 -0
  45. metadata +170 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61569eb14ed0573b4f252e3e7279320bd716ea2d044c5af4c830f33c536608da
4
+ data.tar.gz: 60b89c6b10b38756628c1d0bbefee6bd907309a946d2eac795b0749c456c2d61
5
+ SHA512:
6
+ metadata.gz: 6fdd59ce00cfd0b46024db0bff5750770249b06ccd59dc792cc8d46037262bfe2ef864f29981d1f5028ca48e98be4f232b246b6b26edc764c7bbc17ea521c8cb
7
+ data.tar.gz: 3955736a25363da2379351f8514603509ee976d5130d230de601cd063e185b3c2b1e9143e032cc3d8a5ee7fde9ff688854a55331f6b379db1e40133f9511c3a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2025-10-25
4
+ - Initial gem scaffolding.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vicente
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # Exa Ruby Client
2
+
3
+ > Typed, Sorbet-friendly Ruby bindings for the Exa API, inspired by `openai-ruby` and aligned with Exa’s OpenAPI specs.
4
+
5
+ This README doubles as the canonical “llms-full” reference for the project: it documents the architecture, the current API surface, and the functional-programming patterns we’re porting from OpenAI’s client. If you’re building the Exa Ruby SDK—or just trying to understand how to extend it—start here.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Project Goals](#project-goals)
12
+ 2. [Environment & Installation](#environment--installation)
13
+ 3. [Client Architecture Overview](#client-architecture-overview)
14
+ 4. [Typed Resources & Usage Examples](#typed-resources--usage-examples)
15
+ - [Search stack](#search-stack)
16
+ - [Research](#research)
17
+ - [Websets (core + items + enrichments + monitors)](#websets-core--items--enrichments--monitors)
18
+ - [Events, Imports, Webhooks](#events-imports-webhooks)
19
+ 5. [Structured Output via Sorbet + dspy-schema](#structured-output-via-sorbet--dspy-schema)
20
+ 6. [Streaming & Transport Helpers](#streaming--transport-helpers)
21
+ 7. [Testing & TDD Plan](#testing--tdd-plan)
22
+ 8. [Roadmap / TODOs](#roadmap--todos)
23
+
24
+ ---
25
+
26
+ ## Project Goals
27
+
28
+ - Mirror `openai-ruby` ergonomics so Sorbet-aware developers get typed resources, model structs, and helpers out of the box.
29
+ - Port over OpenAI’s functional patterns: request structs, transport abstraction, streaming/pagination utilities, structured-output DSL.
30
+ - Understand the entire Exa API surface (search, contents, answers, research, websets, monitors, imports, events, webhooks, etc.) and encode it via Sorbet types generated from `openapi-spec/`.
31
+ - Bake Sorbet-generated JSON Schemas directly into v1 using the published `dspy-schema` gem—structured outputs should accept Sorbet types, not free-form hashes.
32
+
33
+ See `docs/architecture.md` for deep-dive notes, mermaid diagrams, and highlights from `openai-ruby`, `exa-py`, and `exa-js`.
34
+
35
+ ---
36
+
37
+ ## Environment & Installation
38
+
39
+ ```
40
+ $ git clone https://github.com/vicentereig/exa-ruby
41
+ $ cd exa-ruby
42
+ $ rbenv install 3.4.5 # .ruby-version already pins this
43
+ $ bundle install
44
+
45
+ # or install from RubyGems (gem name: exa-ai-ruby)
46
+ $ gem install exa-ai-ruby
47
+ ```
48
+
49
+ Runtime dependencies:
50
+ - `sorbet-runtime` – typed structs/enums and runtime assertions.
51
+ - `connection_pool` – `Net::HTTP` pooling in `PooledNetRequester`.
52
+ - `dspy-schema` – converts Sorbet types to JSON Schema (structured output support).
53
+
54
+ Set the API key via `EXA_API_KEY` or pass `api_key:` when instantiating `Exa::Client`.
55
+
56
+ ---
57
+
58
+ ## Client Architecture Overview
59
+
60
+ ```ruby
61
+ require "exa"
62
+
63
+ client = Exa::Client.new(
64
+ api_key: ENV.fetch("EXA_API_KEY"),
65
+ base_url: ENV["EXA_BASE_URL"] || "https://api.exa.ai",
66
+ timeout: 120,
67
+ max_retries: 2
68
+ )
69
+ ```
70
+
71
+ - `Exa::Client` inherits from `Exa::Internal::Transport::BaseClient`, giving us:
72
+ - Header normalization + auth injection (`x-api-key`).
73
+ - Retry/backoff logic with HTTP status checks.
74
+ - Streaming support that returns `Exa::Internal::Transport::Stream`.
75
+ - Request payloads are Sorbet structs under `Exa::Types::*`, serialized via `Exa::Types::Serializer`, which camelizes keys and auto-converts Sorbet schemas (see [Structured Output](#structured-output-via-sorbet--dspy-schema)).
76
+ - Response models live in `lib/exa/responses/*`. Whenever an endpoint returns typed data the resource sets `response_model:` so the client converts the JSON hash into Sorbet structs (e.g., `Exa::Responses::SearchResponse`, `Webset`, `Research`, etc.).
77
+ - Transport stack:
78
+ - `PooledNetRequester` manages per-origin `Net::HTTP` pools via `connection_pool`.
79
+ - Responses stream through fused enumerators so we can decode JSON/JSONL/SSE lazily and ensure sockets are closed once consumers finish iterating.
80
+
81
+ ---
82
+
83
+ ## Typed Resources & Usage Examples
84
+
85
+ ### Search stack
86
+
87
+ ```ruby
88
+ resp = client.search.search(
89
+ query: "latest reasoning LLM papers",
90
+ num_results: 5,
91
+ text: {max_characters: 1_000}
92
+ )
93
+ resp.results.each { puts "#{_1.title} – #{_1.url}" }
94
+
95
+ contents = client.search.contents(urls: ["https://exa.ai"], text: true)
96
+
97
+ # Structured answer with typed search options + Sorbet schema
98
+ class AnswerShape < T::Struct
99
+ const :headline, String
100
+ const :key_points, T::Array[String]
101
+ end
102
+
103
+ answer = client.search.answer(
104
+ query: "Summarize robotics grant funding",
105
+ search_options: {num_results: 3, type: Exa::Types::SearchType::Deep},
106
+ summary: {schema: AnswerShape}
107
+ )
108
+ puts answer.raw # Hash with schema-validated payload
109
+ ```
110
+
111
+ Covers `/search`, `/contents`, `/findSimilar`, and `/answer` with typed request structs (`Exa::Types::SearchRequest`, etc.) and typed responses (`Exa::Responses::SearchResponse`, `FindSimilarResponse`, `ContentsResponse`).
112
+
113
+ ### Research
114
+
115
+ ```ruby
116
+ class ResearchShape < T::Struct
117
+ const :organization, String
118
+ const :funding_rounds, T::Array[String]
119
+ end
120
+
121
+ research = client.research.create(
122
+ instructions: "Map frontier labs & their funders",
123
+ output_schema: ResearchShape
124
+ )
125
+
126
+ # Polling
127
+ details = client.research.get(research.id)
128
+ puts details.status # pending/running/completed
129
+
130
+ # Streaming (Server-Sent Events)
131
+ client.research.get(research.id, stream: true).each_event_json do |event|
132
+ puts "[#{event[:event]}] #{event[:data]}"
133
+ end
134
+
135
+ # Cancel
136
+ client.research.cancel(research.id)
137
+ ```
138
+
139
+ Responses use `Exa::Responses::Research` and `ResearchListResponse`, which preserve raw payloads plus typed attributes (status, operations, events, output hashes, etc.). Streaming helpers (`each_event`, `each_event_json`) live on `Exa::Internal::Transport::Stream`.
140
+
141
+ ### Websets (core + items + enrichments + monitors)
142
+
143
+ ```ruby
144
+ webset = client.websets.create(name: "Competitive Intelligence")
145
+ webset = client.websets.update(webset.id, title: "Updated title")
146
+ ListResp = client.websets.list(limit: 10)
147
+
148
+ # Items
149
+ items = client.websets.items.list(webset.id, limit: 5)
150
+ item = client.websets.items.retrieve(webset.id, items.data.first.id)
151
+ client.websets.items.delete(webset.id, item.id)
152
+
153
+ # Enrichments
154
+ enrichment = client.websets.enrichments.create(
155
+ webset.id,
156
+ description: "Company revenue information",
157
+ format: "text"
158
+ )
159
+ client.websets.enrichments.update(webset.id, enrichment.id, description: "Updated task")
160
+ client.websets.enrichments.cancel(webset.id, enrichment.id)
161
+
162
+ # Monitors
163
+ monitor = client.websets.monitors.create(name: "Daily digest")
164
+ runs = client.websets.monitors.runs_list(monitor.id)
165
+ ```
166
+
167
+ Typed responses:
168
+ - `Exa::Responses::Webset`, `WebsetListResponse`
169
+ - `WebsetItem`, `WebsetItemListResponse`
170
+ - `WebsetEnrichment`
171
+ - `Monitor`, `MonitorRun`, etc.
172
+
173
+ ### Events, Imports, Webhooks
174
+
175
+ ```ruby
176
+ events = client.events.list(types: ["webset.created"])
177
+ event = client.events.retrieve(events.data.first.id)
178
+
179
+ import = client.imports.create(source: {...})
180
+ imports = client.imports.list(limit: 10)
181
+
182
+ webhook = client.webhooks.create(
183
+ url: "https://example.com/hooks",
184
+ events: ["webset.completed"]
185
+ )
186
+ attempts = client.webhooks.attempts(webhook.id, limit: 5)
187
+ ```
188
+
189
+ Every call returns typed structs (`Exa::Responses::Event`, `Import`, `Webhook`, etc.) so consumers get predictable Sorbet shapes.
190
+
191
+ ---
192
+
193
+ ## Structured Output via Sorbet + dspy-schema
194
+
195
+ `dspy-schema`’s Sorbet converter is bundled so any Sorbet `T::Struct`, `T::Enum`, or `T.type_alias` can be dropped into a request payload and automatically serialized to JSON Schema. This powers `summary: {schema: ...}` and `research.output_schema`, letting the API validate outputs against your Sorbet model.
196
+
197
+ Key points:
198
+ - `Exa::Types::Schema.to_json_schema(SomeStruct)` calls `DSPy::TypeSystem::SorbetJsonSchema`.
199
+ - `Exa::Types::Serializer` detects Sorbet classes/aliases before serializing request payloads.
200
+ - Tests in `test/types/serializer_test.rb` ensure schema conversion works end-to-end.
201
+
202
+ ---
203
+
204
+ ## Streaming & Transport Helpers
205
+
206
+ - `Exa::Internal::Transport::Stream` (returned when `stream: true`) exposes:
207
+ - `each` – raw chunk iteration.
208
+ - `each_line` – line-by-line iteration with automatic closing.
209
+ - `each_json_line(symbolize: true)` – NDJSON helper.
210
+ - `each_event` / `each_event_json` – SSE decoding with automatic JSON parsing.
211
+ - `Exa::Internal::Util` utilities:
212
+ - `decode_content` auto-detects JSON/JSONL/SSE vs binary bodies.
213
+ - `decode_lines` + `decode_sse` implement fused enumerators so sockets close exactly once.
214
+ - `PooledNetRequester` calibrates socket timeouts per request deadline and reuses connections via `connection_pool`.
215
+
216
+ See `test/transport/stream_test.rb` for examples.
217
+
218
+ ---
219
+
220
+ ## Testing & TDD Plan
221
+
222
+ Run the suite:
223
+
224
+ ```
225
+ RBENV_VERSION=3.4.5 ~/.rbenv/shims/bundle exec rake test
226
+ ```
227
+
228
+ Current coverage includes:
229
+ - Resource tests for search, research, websets (core/items/enrichments/monitors), imports, events, webhooks, etc., using `TestSupport::FakeRequester`.
230
+ - Type serialization tests ensuring camelCase conversion + schema inference.
231
+ - Streaming helper tests verifying SSE/JSONL decoding.
232
+
233
+ Future tests:
234
+ - End-to-end HTTP tests once a real transport target is wired (probably using recorded fixtures but not VCR).
235
+ - Schema-specific validations once JSON Schema generation is extended to all endpoints.
236
+
237
+ ---
238
+
239
+ ## Roadmap / TODOs
240
+
241
+ 1. **Typed coverage for the remaining OpenAPI endpoints** – webhooks attempts detail, events schema typing, structured outputs for additional research events, etc.
242
+ 2. **Code generation from OpenAPI specs** – automate request/response struct creation so spec updates can be pulled regularly.
243
+ 3. **Transport polish** – retries with `Retry-After`, idempotency keys, better error envelopes mirroring Exa’s payloads.
244
+ 4. **README “llms-full” expansion** – continue updating this document as new features land so it remains the single source of truth.
245
+ 5. **Release packaging** – ensure `.gem` builds exclude dev artifacts (`pkg/`, `.idea/`, etc.) and publish initial versions to RubyGems.
246
+
247
+ Have ideas or find gaps? Open an issue or PR in `vicentereig/exa-ruby`—contributions welcome!***
data/lib/exa/client.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ class Client < Exa::Internal::Transport::BaseClient
5
+ DEFAULT_BASE_URL = "https://api.exa.ai"
6
+
7
+ attr_reader :api_key, :search, :research, :websets, :events, :webhooks, :imports
8
+
9
+ def initialize(
10
+ api_key: ENV["EXA_API_KEY"],
11
+ base_url: ENV["EXA_BASE_URL"] || DEFAULT_BASE_URL,
12
+ **opts
13
+ )
14
+ raise Exa::Errors::ConfigurationError, "api_key is required" if api_key.nil? || api_key.empty?
15
+
16
+ @api_key = api_key
17
+ super(base_url: base_url, **opts)
18
+
19
+ @search = Exa::Resources::Search.new(client: self)
20
+ @research = Exa::Resources::Research.new(client: self)
21
+ @websets = Exa::Resources::Websets.new(client: self)
22
+ @events = Exa::Resources::Events.new(client: self)
23
+ @webhooks = Exa::Resources::Webhooks.new(client: self)
24
+ @imports = Exa::Resources::Imports.new(client: self)
25
+ end
26
+
27
+ private
28
+
29
+ def auth_headers
30
+ {"x-api-key" => api_key}
31
+ end
32
+ end
33
+ end
data/lib/exa/errors.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Errors
5
+ class Error < StandardError
6
+ attr_reader :url, :status, :headers, :body
7
+
8
+ def initialize(message = nil, url: nil, status: nil, headers: nil, body: nil)
9
+ super(message)
10
+ @url = url
11
+ @status = status
12
+ @headers = headers
13
+ @body = body
14
+ end
15
+ end
16
+
17
+ class ConfigurationError < Error; end
18
+ class APIError < Error; end
19
+
20
+ class APIStatusError < APIError
21
+ def self.raise!(url:, status:, headers:, body:)
22
+ message = "Exa API responded with status #{status}"
23
+ raise new(message, url: url, status: status, headers: headers, body: body)
24
+ end
25
+ end
26
+
27
+ class APIConnectionError < APIError; end
28
+ class APITimeoutError < APIError; end
29
+ end
30
+ end
31
+
32
+ module Exa
33
+ Error = Exa::Errors::Error
34
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "cgi"
5
+ require "json"
6
+
7
+ require_relative "../util"
8
+ require_relative "pooled_net_requester"
9
+ require_relative "stream"
10
+ require "exa/errors"
11
+
12
+ module Exa
13
+ module Internal
14
+ module Transport
15
+ class BaseClient
16
+ PLATFORM_HEADERS = {
17
+ "x-stainless-lang" => "ruby",
18
+ "x-stainless-runtime" => RUBY_ENGINE,
19
+ "x-stainless-runtime-version" => RUBY_ENGINE_VERSION
20
+ }.freeze
21
+
22
+ DEFAULT_MAX_RETRIES = 2
23
+ DEFAULT_TIMEOUT = 120.0
24
+ DEFAULT_INITIAL_RETRY_DELAY = 0.5
25
+ DEFAULT_MAX_RETRY_DELAY = 8.0
26
+
27
+ attr_reader :base_url, :timeout, :max_retries, :initial_retry_delay, :max_retry_delay, :headers, :requester
28
+
29
+ def initialize(
30
+ base_url:,
31
+ timeout: DEFAULT_TIMEOUT,
32
+ max_retries: DEFAULT_MAX_RETRIES,
33
+ initial_retry_delay: DEFAULT_INITIAL_RETRY_DELAY,
34
+ max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
35
+ headers: {},
36
+ requester: Exa::Internal::Transport::PooledNetRequester.new
37
+ )
38
+ @base_url = URI(base_url)
39
+ @timeout = timeout
40
+ @max_retries = max_retries
41
+ @initial_retry_delay = initial_retry_delay
42
+ @max_retry_delay = max_retry_delay
43
+ @headers = headers || {}
44
+ @requester = requester
45
+ end
46
+
47
+ def request(method:, path:, query: nil, headers: nil, body: nil, unwrap: nil, stream: false, response_model: nil)
48
+ req = build_request(
49
+ method: method,
50
+ path: Array(path).join("/"),
51
+ query: query,
52
+ headers: headers,
53
+ body: body
54
+ )
55
+
56
+ _, response, stream_enum = send_request(req)
57
+ parsed_headers = Exa::Internal::Util.normalized_headers(response.each_header.to_h)
58
+
59
+ if stream
60
+ Exa::Internal::Transport::Stream.new(headers: parsed_headers, stream: stream_enum)
61
+ else
62
+ decoded = Exa::Internal::Util.decode_content(parsed_headers, stream: stream_enum)
63
+ coerced = coerce_response(response_model, decoded)
64
+ unwrap ? dig(coerced, unwrap) : coerced
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def normalize_path(path)
71
+ segments = Array(path).flat_map do |segment|
72
+ next [] if segment.nil?
73
+ segment.to_s.split("/")
74
+ end
75
+ cleaned = segments.reject(&:empty?)
76
+ return "" if cleaned.empty?
77
+ cleaned.join("/")
78
+ end
79
+
80
+ def build_request(method:, path:, query:, headers:, body:)
81
+ normalized_path = normalize_path(path)
82
+ url = @base_url + normalized_path
83
+ url.query = Exa::Internal::Util.build_query(query)
84
+
85
+ header_overrides = headers ? headers.each_with_object({}) { |(k, v), acc| acc[k] = v unless v.nil? } : {}
86
+ final_headers = PLATFORM_HEADERS.merge(default_headers).merge(header_overrides)
87
+
88
+ payload = case body
89
+ when nil
90
+ nil
91
+ when String
92
+ final_headers["content-type"] ||= "application/json"
93
+ body
94
+ else
95
+ final_headers["content-type"] ||= "application/json"
96
+ JSON.generate(body)
97
+ end
98
+
99
+ {
100
+ method: method,
101
+ url: url,
102
+ headers: final_headers,
103
+ body: payload,
104
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout,
105
+ max_retries: max_retries
106
+ }
107
+ end
108
+
109
+ def default_headers
110
+ merged = headers.merge(auth_headers)
111
+ merged.each_with_object({}) do |(k, v), acc|
112
+ next if v.nil?
113
+ acc[k] = v
114
+ end
115
+ end
116
+
117
+ def auth_headers
118
+ {}
119
+ end
120
+
121
+ def send_request(request, retry_count: 0)
122
+ status, response, body_enum = @requester.execute(request)
123
+ if should_retry?(status) && retry_count < max_retries
124
+ sleep(retry_delay(retry_count))
125
+ return send_request(request, retry_count: retry_count + 1)
126
+ end
127
+
128
+ if status >= 400
129
+ decoded_headers = Exa::Internal::Util.normalized_headers(response.each_header.to_h)
130
+ payload = Exa::Internal::Util.decode_content(decoded_headers, stream: body_enum)
131
+ Exa::Errors::APIStatusError.raise!(
132
+ url: request[:url],
133
+ status: status,
134
+ headers: decoded_headers,
135
+ body: payload
136
+ )
137
+ end
138
+
139
+ [status, response, body_enum]
140
+ rescue Exa::Errors::APITimeoutError, Exa::Errors::APIConnectionError, Exa::Errors::APIStatusError
141
+ raise
142
+ rescue Timeout::Error
143
+ raise Exa::Errors::APITimeoutError.new("Request timed out", url: request[:url])
144
+ rescue StandardError => e
145
+ raise Exa::Errors::APIConnectionError.new(e.message, url: request[:url])
146
+ end
147
+
148
+ def should_retry?(status)
149
+ [408, 409, 429].include?(status) || status >= 500
150
+ end
151
+
152
+ def retry_delay(retry_count)
153
+ delay = initial_retry_delay * (2**retry_count)
154
+ [delay, max_retry_delay].min
155
+ end
156
+
157
+ def dig(obj, path)
158
+ Array(path).reduce(obj) do |memo, key|
159
+ memo.is_a?(Hash) ? memo[key] : nil
160
+ end
161
+ end
162
+
163
+ def coerce_response(model, data)
164
+ return data unless model
165
+ return model.from_hash(data) if model.respond_to?(:from_hash)
166
+ model.new(data)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "connection_pool"
6
+ require "etc"
7
+
8
+ module Exa
9
+ module Internal
10
+ module Transport
11
+ class PooledNetRequester
12
+ KEEP_ALIVE_TIMEOUT = 30
13
+ DEFAULT_MAX_CONNECTIONS = [Etc.nprocessors, 99].max
14
+
15
+ def initialize(size: DEFAULT_MAX_CONNECTIONS)
16
+ @size = size
17
+ @mutex = Mutex.new
18
+ @pools = {}
19
+ end
20
+
21
+ def execute(request)
22
+ url = request.fetch(:url)
23
+ deadline = request.fetch(:deadline)
24
+
25
+ pool_for(url).with(timeout: remaining(deadline)) do |conn|
26
+ req, body_closer = build_request(request) do
27
+ calibrate_socket_timeout(conn, deadline)
28
+ end
29
+
30
+ calibrate_socket_timeout(conn, deadline)
31
+ start_connection(conn)
32
+ calibrate_socket_timeout(conn, deadline)
33
+
34
+ enum = Enumerator.new do |y|
35
+ conn.request(req) do |resp|
36
+ y << [req, resp]
37
+ resp.read_body do |chunk|
38
+ y << chunk.force_encoding(Encoding::BINARY)
39
+ end
40
+ end
41
+ rescue Timeout::Error
42
+ raise Exa::Errors::APITimeoutError.new("Request timed out", url: url)
43
+ rescue StandardError => e
44
+ raise Exa::Errors::APIConnectionError.new(e.message, url: url)
45
+ ensure
46
+ body_closer&.call
47
+ end
48
+
49
+ _, response = enum.next
50
+ body = Exa::Internal::Util.fused_enum(enum)
51
+ [Integer(response.code), response, body]
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def pool_for(url)
58
+ origin = uri_origin(url)
59
+ @mutex.synchronize do
60
+ @pools[origin] ||= ConnectionPool.new(size: @size) { build_connection(url) }
61
+ end
62
+ end
63
+
64
+ def build_connection(url)
65
+ conn = Net::HTTP.new(url.host, url.port)
66
+ conn.use_ssl = url.scheme == "https"
67
+ conn.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
68
+ conn
69
+ end
70
+
71
+ def start_connection(conn)
72
+ return if conn.started?
73
+ conn.start
74
+ end
75
+
76
+ def calibrate_socket_timeout(conn, deadline)
77
+ remaining = remaining(deadline)
78
+ conn.open_timeout = remaining
79
+ conn.read_timeout = remaining
80
+ conn.write_timeout = remaining if conn.respond_to?(:write_timeout=)
81
+ end
82
+
83
+ def remaining(deadline)
84
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
85
+ end
86
+
87
+ def build_request(request)
88
+ method, url, headers, body = request.values_at(:method, :url, :headers, :body)
89
+ req = Net::HTTPGenericRequest.new(method.to_s.upcase, !body.nil?, method != :head, URI(url.to_s))
90
+ headers.each { req[_1] = _2 }
91
+
92
+ case body
93
+ when nil
94
+ when String
95
+ req.body = body
96
+ req["content-length"] = body.bytesize.to_s
97
+ when StringIO
98
+ req.body_stream = body
99
+ req["content-length"] = body.size.to_s
100
+ else
101
+ req.body = body
102
+ end
103
+
104
+ [req, req.body_stream&.method(:close)]
105
+ end
106
+
107
+ def uri_origin(uri)
108
+ [uri.scheme, uri.host, uri.port].join(":")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end