typesafe-sdk-ruby 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +81 -1
- data/LICENSE +1 -0
- data/README.md +365 -94
- data/docs/COMPATIBILITY.md +119 -0
- data/docs/RELEASING.md +38 -0
- data/docs/UPSTREAM_ISSUES.md +74 -0
- data/lib/typesafe/sdk/assets.rb +326 -0
- data/lib/typesafe/sdk/client.rb +98 -30
- data/lib/typesafe/sdk/compression.rb +50 -0
- data/lib/typesafe/sdk/errors.rb +35 -6
- data/lib/typesafe/sdk/http.rb +58 -9
- data/lib/typesafe/sdk/logging.rb +19 -7
- data/lib/typesafe/sdk/questions.rb +74 -35
- data/lib/typesafe/sdk/resources/models.rb +6 -4
- data/lib/typesafe/sdk/retry.rb +4 -4
- data/lib/typesafe/sdk/types.rb +69 -11
- data/lib/typesafe/sdk/version.rb +3 -2
- data/lib/typesafe-sdk-ruby.rb +30 -0
- metadata +17 -7
data/lib/typesafe/sdk/client.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Typesafe
|
|
|
15
15
|
# Client for the TypeSafe AI API.
|
|
16
16
|
class Client
|
|
17
17
|
attr_reader :base_url, :default_model, :log_level, :logger, :retry, :timeout,
|
|
18
|
-
:default_headers, :models
|
|
18
|
+
:default_headers, :compression, :models
|
|
19
19
|
|
|
20
20
|
# Create a client for the TypeSafe AI API.
|
|
21
21
|
#
|
|
@@ -30,13 +30,16 @@ module Typesafe
|
|
|
30
30
|
# @param retry_policy [Hash, nil] overrides for {Retry::DEFAULT_RETRY_POLICY}.
|
|
31
31
|
# @param timeout [Float, nil] timeout per attempt in seconds. Default: 10.
|
|
32
32
|
# @param default_headers [Hash, nil] additional request headers.
|
|
33
|
+
# @param compression [Symbol, false, nil] request body compression, sent as `Content-Encoding`.
|
|
34
|
+
# Default: `:zstd` when the optional `zstd-ruby` gem can be loaded, otherwise `false`.
|
|
35
|
+
# Bodies under 1 KiB are always sent uncompressed.
|
|
33
36
|
# @param http [Object] HTTP adapter responding to `request`. Default: {HTTP}.
|
|
34
|
-
# @raise [TypeSafeError] the API key is missing
|
|
37
|
+
# @raise [TypeSafeError] the API key is missing, configuration is invalid, or zstd
|
|
38
|
+
# compression is requested without the `zstd-ruby` gem.
|
|
35
39
|
def initialize(api_key: nil, base_url: nil, default_model: nil, log_level: nil,
|
|
36
40
|
logger: nil, retry_policy: nil, timeout: nil, default_headers: nil,
|
|
37
|
-
http: nil, env: ::ENV)
|
|
38
|
-
@api_key =
|
|
39
|
-
raise TypeSafeError, missing_api_key_message if @api_key.nil? || @api_key.empty?
|
|
41
|
+
compression: nil, http: nil, env: ::ENV)
|
|
42
|
+
@api_key = validate_api_key(api_key.nil? ? env[ENV::API_KEY] : api_key)
|
|
40
43
|
|
|
41
44
|
@base_url = strip_trailing_slashes(
|
|
42
45
|
ENV.from_code_or_env(base_url, ENV::BASE_URL, source: env) || DEFAULT_BASE_URL
|
|
@@ -49,20 +52,30 @@ module Typesafe
|
|
|
49
52
|
@retry = resolve_retry_policy(Retry::DEFAULT_RETRY_POLICY, retry_policy)
|
|
50
53
|
@timeout = assert_positive("timeout", timeout || Retry::DEFAULT_TIMEOUT_S)
|
|
51
54
|
@default_headers = (default_headers || {}).dup.freeze
|
|
55
|
+
@compression = Compression.resolve(compression)
|
|
52
56
|
@http = http || HTTP.new
|
|
53
57
|
@models = Resources::Models.new(self)
|
|
54
58
|
@request_count = 0
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
# Answer named questions about text
|
|
61
|
+
# Answer named questions about text, structured state, or images.
|
|
58
62
|
#
|
|
59
|
-
#
|
|
63
|
+
# Images in `state`, question content, or {SDK.with_assets} text are sent under their
|
|
64
|
+
# references; requests with images are sent as `multipart/form-data`.
|
|
65
|
+
#
|
|
66
|
+
# @param state [String, Hash, Array, Image, TextWithAssets] the content to evaluate.
|
|
60
67
|
# @param questions [Hash{Symbol, String => Hash}] nonempty questions keyed by name.
|
|
61
68
|
# @param model [String, nil] model override; omitted values inherit `default_model`.
|
|
62
|
-
# @param
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
69
|
+
# @param assets [Hash{String => Image}, nil] images keyed by names of your choice, matched
|
|
70
|
+
# literally in strings within `state`, question instructions, or criteria. Every name must
|
|
71
|
+
# appear in the request.
|
|
72
|
+
# @param extra_body [Hash] additional JSON fields; named request arguments take precedence.
|
|
73
|
+
# @param with_response [Boolean] return data with HTTP metadata and request ID.
|
|
74
|
+
# @param options [Hash] per-call `timeout`, `retry_policy`, `headers`, and `signal` settings.
|
|
75
|
+
# @return [SystemOneResult, WithResponse] answers keyed by question name, with model and token usage.
|
|
76
|
+
# @raise [TypeSafeError] state or questions violate the locally checked API constraints,
|
|
77
|
+
# the request holds a cycle, a string holds the reference of an image not attached to the
|
|
78
|
+
# request, a name in `assets` doesn't appear in the request, or an image can't be read.
|
|
66
79
|
# @raise [APIError] the server returns a non-2xx response after retries.
|
|
67
80
|
# @raise [APIConnectionError] the request cannot connect or times out after retries.
|
|
68
81
|
# @raise [APIUserAbortError] the caller cancels the request.
|
|
@@ -72,17 +85,30 @@ module Typesafe
|
|
|
72
85
|
# state: "I was charged twice. Please help.",
|
|
73
86
|
# questions: { billing: Typesafe::SDK.noul("Is this about billing?") },
|
|
74
87
|
# )
|
|
75
|
-
# response
|
|
76
|
-
def system_one(state:, questions:, model: nil,
|
|
88
|
+
# response[:billing].noul # => 0.93
|
|
89
|
+
def system_one(state:, questions:, model: nil, assets: nil, extra_body: {}, with_response: false,
|
|
90
|
+
**options)
|
|
91
|
+
Questions.validate_state!(state)
|
|
77
92
|
Questions.validate!(questions)
|
|
78
|
-
|
|
93
|
+
named = Assets.normalize(assets)
|
|
94
|
+
request_body = extra_body.transform_keys(&:to_s).merge(
|
|
95
|
+
"state" => state, "questions" => questions, "model" => model || @default_model
|
|
96
|
+
)
|
|
97
|
+
# Serialized now, so changes to the request after this call don't change what is sent.
|
|
98
|
+
json, sent = Assets.serialize_request(request_body, named)
|
|
99
|
+
payload = JSON.parse(json)
|
|
100
|
+
body = sent.nil? ? payload : -> { Assets.encode(payload, sent) }
|
|
79
101
|
response = request(:post, "/v1/systemone", body: body, **options)
|
|
80
|
-
SystemOneResult.new(response.body)
|
|
102
|
+
result = SystemOneResult.new(response.body)
|
|
103
|
+
with_response ? WithResponse.new(result, response) : result
|
|
81
104
|
end
|
|
82
105
|
|
|
83
106
|
# Send a request and parse its response body. Internal; used by API resources.
|
|
84
107
|
#
|
|
85
|
-
#
|
|
108
|
+
# `body` is a JSON-ready object, or a callable returning one or an {MultipartBody},
|
|
109
|
+
# called once before the first attempt.
|
|
110
|
+
#
|
|
111
|
+
# @return [Response] the parsed response, with `body`, `status`, `headers`, and `request_id`.
|
|
86
112
|
def request(method, path, body: nil, headers: {}, timeout: nil, retry_policy: nil,
|
|
87
113
|
signal: nil)
|
|
88
114
|
resolved = {
|
|
@@ -106,6 +132,21 @@ module Typesafe
|
|
|
106
132
|
|
|
107
133
|
private
|
|
108
134
|
|
|
135
|
+
def validate_api_key(value)
|
|
136
|
+
raise TypeSafeError, missing_api_key_message if value.nil?
|
|
137
|
+
unless value.is_a?(String) && value.valid_encoding? && value.ascii_only?
|
|
138
|
+
raise TypeSafeError, "API key must be an ASCII string."
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
key = value.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, "")
|
|
142
|
+
raise TypeSafeError, missing_api_key_message if key.empty?
|
|
143
|
+
if key.match?(/[\x00-\x20\x7f]/)
|
|
144
|
+
raise TypeSafeError, "API key must not contain whitespace or control characters."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
key.freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
109
150
|
def missing_api_key_message
|
|
110
151
|
"No API key was provided. Pass `api_key:` to Typesafe::SDK::Client.new or set the " \
|
|
111
152
|
"#{ENV::API_KEY} environment variable."
|
|
@@ -121,7 +162,7 @@ module Typesafe
|
|
|
121
162
|
end
|
|
122
163
|
|
|
123
164
|
def assert_positive(name, value)
|
|
124
|
-
unless value.is_a?(Numeric) && value.positive?
|
|
165
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
125
166
|
raise TypeSafeError,
|
|
126
167
|
"`#{name}` must be a positive number, got #{value.inspect}."
|
|
127
168
|
end
|
|
@@ -138,7 +179,7 @@ module Typesafe
|
|
|
138
179
|
end
|
|
139
180
|
|
|
140
181
|
def assert_non_negative(name, value)
|
|
141
|
-
unless value.is_a?(Numeric) && value >= 0
|
|
182
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0
|
|
142
183
|
raise TypeSafeError, "`#{name}` must be a non-negative number, got #{value.inspect}."
|
|
143
184
|
end
|
|
144
185
|
|
|
@@ -146,7 +187,7 @@ module Typesafe
|
|
|
146
187
|
end
|
|
147
188
|
|
|
148
189
|
def assert_fraction(name, value)
|
|
149
|
-
unless value.is_a?(Numeric) && value >= 0 && value <= 1
|
|
190
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0 && value <= 1
|
|
150
191
|
raise TypeSafeError, "`#{name}` must be between 0 and 1, got #{value.inspect}."
|
|
151
192
|
end
|
|
152
193
|
|
|
@@ -159,7 +200,7 @@ module Typesafe
|
|
|
159
200
|
raise TypeSafeError, "`#{name}` must contain HTTP status codes, got #{status.inspect}."
|
|
160
201
|
end
|
|
161
202
|
end
|
|
162
|
-
statuses
|
|
203
|
+
statuses.freeze
|
|
163
204
|
end
|
|
164
205
|
|
|
165
206
|
# Merge and validate retry overrides, copying the status list to isolate later mutations.
|
|
@@ -196,18 +237,23 @@ module Typesafe
|
|
|
196
237
|
else
|
|
197
238
|
base[:http_statuses]
|
|
198
239
|
end,
|
|
199
|
-
respect_retry_after:
|
|
240
|
+
respect_retry_after: resolve_retry_flag(base, o, :respect_retry_after),
|
|
200
241
|
max_retry_after_ms: if o.key?(:max_retry_after_ms)
|
|
201
242
|
assert_non_negative("retry.max_retry_after_ms",
|
|
202
243
|
o[:max_retry_after_ms])
|
|
203
244
|
else
|
|
204
245
|
base[:max_retry_after_ms]
|
|
205
246
|
end,
|
|
206
|
-
api_connection_error:
|
|
207
|
-
api_timeout_error:
|
|
247
|
+
api_connection_error: resolve_retry_flag(base, o, :api_connection_error),
|
|
248
|
+
api_timeout_error: resolve_retry_flag(base, o, :api_timeout_error)
|
|
208
249
|
}.freeze
|
|
209
250
|
end
|
|
210
251
|
|
|
252
|
+
# Like upstream's nullish fallback, nil inherits while false stays explicit.
|
|
253
|
+
def resolve_retry_flag(base, overrides, name)
|
|
254
|
+
overrides[name].nil? ? base[name] : overrides[name]
|
|
255
|
+
end
|
|
256
|
+
|
|
211
257
|
# Last value wins regardless of casing; `nil` removes a protected header.
|
|
212
258
|
def merge_headers(*sources)
|
|
213
259
|
merged = {}
|
|
@@ -227,27 +273,29 @@ module Typesafe
|
|
|
227
273
|
# Retry eligible failures, logging attempt summaries at `info` and details at `debug`.
|
|
228
274
|
def fetch_with_retries(tag, req)
|
|
229
275
|
url = "#{@base_url}#{req[:path]}"
|
|
230
|
-
|
|
276
|
+
multipart, payload, encoded, encoding, body = prepare_body(tag, req)
|
|
277
|
+
# User-supplied headers go first so they can't clobber auth, the content type, or the encoding.
|
|
231
278
|
headers = merge_headers(req[:headers], {
|
|
232
279
|
"Authorization" => "Bearer #{@api_key}",
|
|
233
280
|
"Accept" => "application/json",
|
|
234
281
|
"User-Agent" => "typesafe-sdk-ruby/#{VERSION}",
|
|
235
282
|
"X-TypeSafe-SDK" => "typesafe-sdk-ruby/#{VERSION}",
|
|
236
283
|
"X-TypeSafe-Runtime" => "ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})",
|
|
237
|
-
"Content-Type" =>
|
|
284
|
+
"Content-Type" => multipart&.content_type ||
|
|
285
|
+
(encoded.nil? ? nil : "application/json"),
|
|
286
|
+
"Content-Encoding" => (encoding.to_s if encoding),
|
|
238
287
|
"X-TypeSafe-Retry-Count" => nil
|
|
239
288
|
})
|
|
240
|
-
payload = req[:body].nil? ? nil : JSON.generate(req[:body])
|
|
241
289
|
|
|
242
290
|
attempt = 0
|
|
243
291
|
loop do
|
|
244
292
|
retries_left = req[:retry][:max_retries] - attempt
|
|
245
293
|
attempt_headers = attempt.zero? ? headers : headers.merge("X-TypeSafe-Retry-Count" => attempt.to_s)
|
|
246
|
-
@logger.debug("#{tag} -> #{url}", headers: Redaction.redact_headers(attempt_headers), body:
|
|
294
|
+
@logger.debug("#{tag} -> #{url}", headers: Redaction.redact_headers(attempt_headers), body: payload)
|
|
247
295
|
|
|
248
296
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
249
297
|
begin
|
|
250
|
-
response = attempt_request(tag, url, req[:method], attempt_headers,
|
|
298
|
+
response = attempt_request(tag, url, req[:method], attempt_headers, body, req)
|
|
251
299
|
rescue APIUserAbortError
|
|
252
300
|
raise
|
|
253
301
|
rescue APIConnectionError => e
|
|
@@ -272,6 +320,26 @@ module Typesafe
|
|
|
272
320
|
end
|
|
273
321
|
end
|
|
274
322
|
|
|
323
|
+
# Resolve, encode, and compress the body once; every attempt sends the same bytes.
|
|
324
|
+
# The logs show the uncompressed payload.
|
|
325
|
+
def prepare_body(tag, req)
|
|
326
|
+
resolved = req[:body]
|
|
327
|
+
if resolved.respond_to?(:call)
|
|
328
|
+
req[:signal]&.check!
|
|
329
|
+
resolved = resolved.call
|
|
330
|
+
if req[:signal]&.canceled?
|
|
331
|
+
@logger.info("#{tag} aborted by caller while reading assets")
|
|
332
|
+
raise APIUserAbortError
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
multipart = resolved.is_a?(MultipartBody) ? resolved : nil
|
|
336
|
+
payload = multipart ? multipart.payload : resolved
|
|
337
|
+
encoded = multipart&.content || (payload.nil? ? nil : JSON.generate(payload))
|
|
338
|
+
encoding = encoded && encoded.bytesize >= Compression::MIN_COMPRESSED_BODY_BYTES ? @compression : false
|
|
339
|
+
body = encoding ? Compression.compress(encoded) : encoded
|
|
340
|
+
[multipart, payload, encoded, encoding, body]
|
|
341
|
+
end
|
|
342
|
+
|
|
275
343
|
def retryable_connection_error?(error, policy)
|
|
276
344
|
return policy[:api_timeout_error] if error.is_a?(APITimeoutError)
|
|
277
345
|
|
|
@@ -312,9 +380,9 @@ module Typesafe
|
|
|
312
380
|
sleep_with_signal(delay, req[:signal])
|
|
313
381
|
end
|
|
314
382
|
|
|
315
|
-
def sleep_with_signal(
|
|
383
|
+
def sleep_with_signal(milliseconds, signal)
|
|
316
384
|
signal&.check!
|
|
317
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (
|
|
385
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (milliseconds / 1000.0)
|
|
318
386
|
loop do
|
|
319
387
|
if signal
|
|
320
388
|
signal.check!
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
# Request body compression with zstd, provided by the optional `zstd-ruby` gem.
|
|
6
|
+
module Compression
|
|
7
|
+
# Bodies below this many bytes fit in a single TCP segment, so they are sent uncompressed.
|
|
8
|
+
MIN_COMPRESSED_BODY_BYTES = 1024
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Whether the `zstd-ruby` gem can be loaded. Checked once per process.
|
|
13
|
+
def available?
|
|
14
|
+
return @available unless @available.nil?
|
|
15
|
+
|
|
16
|
+
@available = begin
|
|
17
|
+
require "zstd-ruby"
|
|
18
|
+
true
|
|
19
|
+
rescue LoadError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Resolve the `compression:` option to the `Content-Encoding` request bodies are sent with:
|
|
25
|
+
# `:zstd` when the runtime has it, otherwise `false`.
|
|
26
|
+
#
|
|
27
|
+
# @raise [TypeSafeError] the value isn't a supported option, or zstd is unavailable.
|
|
28
|
+
def resolve(compression)
|
|
29
|
+
return available? ? :zstd : false if compression.nil?
|
|
30
|
+
return false if compression == false
|
|
31
|
+
|
|
32
|
+
unless [:zstd, "zstd"].include?(compression)
|
|
33
|
+
raise TypeSafeError, "`compression` must be :zstd or false, got #{compression.inspect}."
|
|
34
|
+
end
|
|
35
|
+
unless available?
|
|
36
|
+
raise TypeSafeError,
|
|
37
|
+
"zstd compression requires the zstd-ruby gem. Add it to your Gemfile or pass " \
|
|
38
|
+
"`compression: false` instead."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
:zstd
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Compress a request body with zstd.
|
|
45
|
+
def compress(body)
|
|
46
|
+
Zstd.compress(body).b
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/typesafe/sdk/errors.rb
CHANGED
|
@@ -26,16 +26,19 @@ module Typesafe
|
|
|
26
26
|
attr_reader :request_id
|
|
27
27
|
|
|
28
28
|
def initialize(status, body, headers, message = nil)
|
|
29
|
-
super(message || APIError.describe(status, body))
|
|
29
|
+
super(message || APIError.describe(status, body, headers))
|
|
30
30
|
@status = status
|
|
31
31
|
@body = body
|
|
32
32
|
@headers = headers
|
|
33
33
|
@request_id = headers[Typesafe::SDK::REQUEST_ID_HEADER]
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def self.describe(status, body)
|
|
36
|
+
def self.describe(status, body, headers = nil)
|
|
37
|
+
edge = describe_edge_block(status, body, headers)
|
|
38
|
+
return edge if edge
|
|
39
|
+
|
|
37
40
|
detail = extract_message(body)
|
|
38
|
-
return "#{status} #{detail}" if detail
|
|
41
|
+
return "#{status} #{detail}" if detail && !detail.empty?
|
|
39
42
|
return "#{status} status code (no body)" if body.nil?
|
|
40
43
|
|
|
41
44
|
raw = body.is_a?(String) ? body : JSON.generate(body)
|
|
@@ -56,8 +59,11 @@ module Typesafe
|
|
|
56
59
|
@status_classes ||= {
|
|
57
60
|
400 => Typesafe::SDK::BadRequestError,
|
|
58
61
|
401 => Typesafe::SDK::AuthenticationError,
|
|
62
|
+
402 => Typesafe::SDK::PaymentRequiredError,
|
|
59
63
|
403 => Typesafe::SDK::PermissionDeniedError,
|
|
60
64
|
404 => Typesafe::SDK::NotFoundError,
|
|
65
|
+
409 => Typesafe::SDK::ConflictError,
|
|
66
|
+
413 => Typesafe::SDK::PayloadTooLargeError,
|
|
61
67
|
422 => Typesafe::SDK::UnprocessableEntityError,
|
|
62
68
|
429 => Typesafe::SDK::RateLimitError
|
|
63
69
|
}.freeze
|
|
@@ -78,6 +84,20 @@ module Typesafe
|
|
|
78
84
|
nil
|
|
79
85
|
end
|
|
80
86
|
|
|
87
|
+
# Describe an HTML page from Cloudflare, which answers before the request reaches the API,
|
|
88
|
+
# for example when its firewall matches text such as shell commands in the request.
|
|
89
|
+
def self.describe_edge_block(status, body, headers)
|
|
90
|
+
return nil unless headers && body.is_a?(String) && body.match?(/\A\s*<(?:!doctype|html)/i)
|
|
91
|
+
|
|
92
|
+
ray = headers["cf-ray"]
|
|
93
|
+
return nil unless ray || headers["server"].to_s.match?(/cloudflare/i)
|
|
94
|
+
|
|
95
|
+
"#{status} The response is a Cloudflare HTML page, not a TypeSafe API error" \
|
|
96
|
+
"#{" (cf-ray #{ray})" if ray}. An edge firewall may have blocked the request before it " \
|
|
97
|
+
"reached the API; content such as shell commands in the request can trigger it. " \
|
|
98
|
+
"Report the ray ID to TypeSafe support if the request is valid."
|
|
99
|
+
end
|
|
100
|
+
|
|
81
101
|
# Format validation errors as semicolon-separated `path: message` entries.
|
|
82
102
|
def self.describe_validation_errors(errors)
|
|
83
103
|
parts = errors.filter_map do |e|
|
|
@@ -94,18 +114,27 @@ module Typesafe
|
|
|
94
114
|
class BadRequestError < APIError; end
|
|
95
115
|
# HTTP 401: authentication failed.
|
|
96
116
|
class AuthenticationError < APIError; end
|
|
117
|
+
# HTTP 402: payment is required.
|
|
118
|
+
class PaymentRequiredError < APIError; end
|
|
97
119
|
# HTTP 403: access is denied.
|
|
98
120
|
class PermissionDeniedError < APIError; end
|
|
99
121
|
# HTTP 404: the resource was not found.
|
|
100
122
|
class NotFoundError < APIError; end
|
|
123
|
+
# HTTP 409: the request conflicts with the current state.
|
|
124
|
+
class ConflictError < APIError; end
|
|
125
|
+
# HTTP 413: the request payload is too large.
|
|
126
|
+
class PayloadTooLargeError < APIError; end
|
|
101
127
|
# HTTP 422: request validation failed.
|
|
102
128
|
class UnprocessableEntityError < APIError; end
|
|
103
129
|
|
|
104
130
|
# HTTP 429: the rate limit was exceeded.
|
|
105
131
|
class RateLimitError < APIError
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
# Captured at response time, so HTTP-date delays do not change between reads.
|
|
133
|
+
attr_reader :retry_after_ms
|
|
134
|
+
|
|
135
|
+
def initialize(...)
|
|
136
|
+
super
|
|
137
|
+
@retry_after_ms = Typesafe::SDK::Retry.parse_retry_after(@headers)
|
|
109
138
|
end
|
|
110
139
|
end
|
|
111
140
|
|
data/lib/typesafe/sdk/http.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Typesafe
|
|
|
9
9
|
def initialize
|
|
10
10
|
@mutex = Mutex.new
|
|
11
11
|
@canceled = false
|
|
12
|
+
@condition = ConditionVariable.new
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def canceled?
|
|
@@ -16,7 +17,10 @@ module Typesafe
|
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def cancel
|
|
19
|
-
@mutex.synchronize
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@canceled = true
|
|
22
|
+
@condition.broadcast
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# Raise {APIUserAbortError} if canceled.
|
|
@@ -27,8 +31,10 @@ module Typesafe
|
|
|
27
31
|
# Wait up to `seconds`, returning early when canceled.
|
|
28
32
|
def wait(seconds)
|
|
29
33
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
while !@canceled && (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
36
|
+
@condition.wait(@mutex, remaining)
|
|
37
|
+
end
|
|
32
38
|
end
|
|
33
39
|
end
|
|
34
40
|
end
|
|
@@ -60,7 +66,8 @@ module Typesafe
|
|
|
60
66
|
# @param method [Symbol] `:get` or `:post`.
|
|
61
67
|
# @param url [String] absolute URL.
|
|
62
68
|
# @param headers [Hash{String => String}] request headers.
|
|
63
|
-
# @param body [String, nil]
|
|
69
|
+
# @param body [String, nil] encoded request body: JSON, or `multipart/form-data` when the
|
|
70
|
+
# request has images, compressed when `Content-Encoding` is set. Send it as-is.
|
|
64
71
|
# @param timeout [Numeric] timeout in seconds.
|
|
65
72
|
# @param signal [Signal, nil] cancellation handle.
|
|
66
73
|
# @return [Response]
|
|
@@ -69,6 +76,7 @@ module Typesafe
|
|
|
69
76
|
check_signal!(signal)
|
|
70
77
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
71
78
|
http.use_ssl = uri.scheme == "https"
|
|
79
|
+
http.max_retries = 0 # Only the SDK owns retries and retry-count headers.
|
|
72
80
|
http.open_timeout = timeout
|
|
73
81
|
http.read_timeout = timeout
|
|
74
82
|
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
@@ -78,9 +86,7 @@ module Typesafe
|
|
|
78
86
|
headers.each { |name, value| req[name] = value }
|
|
79
87
|
req.body = body if body
|
|
80
88
|
|
|
81
|
-
|
|
82
|
-
raw = http.request(req)
|
|
83
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
89
|
+
raw = perform(http, req, timeout, signal)
|
|
84
90
|
body_text = raw.body.to_s
|
|
85
91
|
parsed = parse_body(body_text, raw["content-type"])
|
|
86
92
|
Response.new(
|
|
@@ -93,13 +99,56 @@ module Typesafe
|
|
|
93
99
|
raise
|
|
94
100
|
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
95
101
|
raise APITimeoutError.new((timeout * 1000).round, cause: e)
|
|
96
|
-
rescue
|
|
97
|
-
|
|
102
|
+
rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError, EOFError, IOError,
|
|
103
|
+
Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => e
|
|
98
104
|
raise APIConnectionError.new("Connection error: #{e.message}", cause: e)
|
|
99
105
|
end
|
|
100
106
|
|
|
101
107
|
private
|
|
102
108
|
|
|
109
|
+
# Net::HTTP's read timeout resets for every read. A private worker bounds the
|
|
110
|
+
# whole connection/upload/body lifecycle without interrupting the caller thread.
|
|
111
|
+
# Killing it runs Net::HTTP's ensure blocks; join completes socket cleanup before return.
|
|
112
|
+
def perform(http, request, timeout, signal)
|
|
113
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
114
|
+
worker = Thread.new do
|
|
115
|
+
Thread.current.report_on_exception = false
|
|
116
|
+
begin
|
|
117
|
+
[read_response(http, request), nil]
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
[nil, e]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
loop do
|
|
123
|
+
signal&.check!
|
|
124
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
125
|
+
raise APITimeoutError, (timeout * 1000).round unless remaining.positive?
|
|
126
|
+
break if worker.join([remaining, 0.01].min)
|
|
127
|
+
end
|
|
128
|
+
signal&.check!
|
|
129
|
+
response, error = worker.value
|
|
130
|
+
raise error if error
|
|
131
|
+
|
|
132
|
+
response
|
|
133
|
+
ensure
|
|
134
|
+
worker&.kill&.join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read_response(http, request)
|
|
138
|
+
http.start do
|
|
139
|
+
http.request(request) do |response|
|
|
140
|
+
response.ignore_eof = false if response.respond_to?(:ignore_eof=)
|
|
141
|
+
response.read_body
|
|
142
|
+
# Older Ruby releases silently accept short Content-Length bodies.
|
|
143
|
+
# Net::HTTP removes Content-Length when automatically decompressing.
|
|
144
|
+
length = response["content-length"]
|
|
145
|
+
if length && response.body && response.body.bytesize < length.to_i
|
|
146
|
+
raise EOFError, "Response body ended before Content-Length bytes arrived"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
103
152
|
def check_signal!(signal)
|
|
104
153
|
signal&.check!
|
|
105
154
|
end
|
data/lib/typesafe/sdk/logging.rb
CHANGED
|
@@ -64,7 +64,11 @@ module Typesafe
|
|
|
64
64
|
define_method(severity) do |message, **data|
|
|
65
65
|
return if LogLevel::RANK.fetch(severity) < @rank
|
|
66
66
|
|
|
67
|
-
@sink.
|
|
67
|
+
if data.empty? || @sink.method(severity).parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
|
|
68
|
+
@sink.public_send(severity, message, **data)
|
|
69
|
+
else
|
|
70
|
+
@sink.public_send(severity, "#{message} #{data.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}")
|
|
71
|
+
end
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
end
|
|
@@ -72,20 +76,28 @@ module Typesafe
|
|
|
72
76
|
# Credential headers that retain a key suffix for identification.
|
|
73
77
|
module Redaction
|
|
74
78
|
KEY_HEADERS = %w[authorization proxy-authorization x-api-key].freeze
|
|
79
|
+
# Headers whose values may start with an authentication scheme such as `Bearer`.
|
|
80
|
+
SCHEME_HEADERS = %w[authorization proxy-authorization].freeze
|
|
75
81
|
OPAQUE_HEADERS = %w[cookie set-cookie].freeze
|
|
82
|
+
# An alphabetic scheme followed by a nonempty credential (RFC 9110 §11.4).
|
|
83
|
+
SCHEMED = /\A([A-Za-z]+)[ \t]+(\S.*)\z/m
|
|
76
84
|
|
|
77
85
|
module_function
|
|
78
86
|
|
|
79
|
-
# Mask a key,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
# Mask a key, keeping the last four characters of secrets longer than eight. An alphabetic
|
|
88
|
+
# leading scheme, such as `Bearer`, is kept only when `scheme` is true and a credential
|
|
89
|
+
# follows it; otherwise the whole value is masked, so a secret is never shown as a scheme.
|
|
90
|
+
def redact_key(value, scheme: true)
|
|
91
|
+
value = value.to_s
|
|
92
|
+
match = scheme ? SCHEMED.match(value) : nil
|
|
93
|
+
prefix, secret = match ? ["#{match[1]} ", match[2].strip] : ["", value.strip]
|
|
94
|
+
tail = secret.length > 8 ? secret[-4..] : ""
|
|
95
|
+
"#{prefix}***#{tail}"
|
|
84
96
|
end
|
|
85
97
|
|
|
86
98
|
def redact(name, value)
|
|
87
99
|
lower = name.downcase
|
|
88
|
-
return redact_key(value) if KEY_HEADERS.include?(lower)
|
|
100
|
+
return redact_key(value, scheme: SCHEME_HEADERS.include?(lower)) if KEY_HEADERS.include?(lower)
|
|
89
101
|
return "***" if OPAQUE_HEADERS.include?(lower)
|
|
90
102
|
|
|
91
103
|
value
|