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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE +21 -0
- data/README.md +247 -0
- data/lib/exa/client.rb +33 -0
- data/lib/exa/errors.rb +34 -0
- data/lib/exa/internal/transport/base_client.rb +171 -0
- data/lib/exa/internal/transport/pooled_net_requester.rb +113 -0
- data/lib/exa/internal/transport/stream.rb +74 -0
- data/lib/exa/internal/util.rb +133 -0
- data/lib/exa/resources/base.rb +26 -0
- data/lib/exa/resources/events.rb +32 -0
- data/lib/exa/resources/imports.rb +58 -0
- data/lib/exa/resources/research.rb +50 -0
- data/lib/exa/resources/search.rb +44 -0
- data/lib/exa/resources/webhooks.rb +67 -0
- data/lib/exa/resources/websets/enrichments.rb +57 -0
- data/lib/exa/resources/websets/items.rb +40 -0
- data/lib/exa/resources/websets/monitors.rb +75 -0
- data/lib/exa/resources/websets.rb +71 -0
- data/lib/exa/resources.rb +9 -0
- data/lib/exa/responses/contents_response.rb +35 -0
- data/lib/exa/responses/event_response.rb +43 -0
- data/lib/exa/responses/helpers.rb +29 -0
- data/lib/exa/responses/import_response.rb +90 -0
- data/lib/exa/responses/monitor_response.rb +77 -0
- data/lib/exa/responses/raw_response.rb +14 -0
- data/lib/exa/responses/research_response.rb +56 -0
- data/lib/exa/responses/result.rb +61 -0
- data/lib/exa/responses/search_response.rb +43 -0
- data/lib/exa/responses/webhook_response.rb +95 -0
- data/lib/exa/responses/webset_response.rb +136 -0
- data/lib/exa/responses.rb +13 -0
- data/lib/exa/types/answer.rb +30 -0
- data/lib/exa/types/base.rb +66 -0
- data/lib/exa/types/contents.rb +25 -0
- data/lib/exa/types/enums.rb +47 -0
- data/lib/exa/types/find_similar.rb +26 -0
- data/lib/exa/types/research.rb +18 -0
- data/lib/exa/types/schema.rb +58 -0
- data/lib/exa/types/search.rb +74 -0
- data/lib/exa/types.rb +10 -0
- data/lib/exa/version.rb +5 -0
- data/lib/exa.rb +14 -0
- 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
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
|