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.
@@ -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 or configuration is invalid.
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 = ENV.from_code_or_env(api_key, ENV::API_KEY, source: env)
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 or structured state.
61
+ # Answer named questions about text, structured state, or images.
58
62
  #
59
- # @param state [String, Hash, Array, nil] the content to evaluate.
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 options [Hash] per-call `timeout`, `retry`, `headers`, and `signal` settings.
63
- # @return [SystemOneResult] answers keyed by question name, with model and token usage.
64
- # @raise [TypeSafeError] questions are empty, or score criteria are not a list of at
65
- # least two entries.
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.answers[:billing].noul # => 0.93
76
- def system_one(state:, questions:, model: nil, **options)
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
- body = { state: state, questions: questions, model: model || @default_model }
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
- # @return [Response] the parsed response, with `data`, `status`, `headers`, and `request_id`.
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: o.fetch(:respect_retry_after, base[: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: o.fetch(:api_connection_error, base[:api_connection_error]),
207
- api_timeout_error: o.fetch(:api_timeout_error, base[: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
- # User-supplied headers go first so they can't clobber auth or the JSON content type.
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" => req[:body].nil? ? nil : "application/json",
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: req[: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, payload, req)
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(seconds, signal)
383
+ def sleep_with_signal(milliseconds, signal)
316
384
  signal&.check!
317
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (seconds / 1000.0)
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
@@ -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
- # Server retry delay in milliseconds, or `nil` when absent or invalid.
107
- def retry_after_ms
108
- Typesafe::SDK::Retry.parse_retry_after(@headers)
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
 
@@ -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 { @canceled = true }
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
- while !canceled? && (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
31
- sleep([remaining, 0.05].min)
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] JSON-encoded request body.
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
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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 Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
97
- SocketError, OpenSSL::SSL::SSLError, EOFError, IOError => e
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
@@ -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.public_send(severity, message, **data)
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, preserving its scheme and the last four characters of secrets longer than eight.
80
- def redact_key(value)
81
- scheme, secret = value.include?(" ") ? value.split(/\s+/, 2) : [nil, value]
82
- tail = secret && secret.length > 8 ? secret[-4..] : ""
83
- "#{"#{scheme} " if scheme}***#{tail}"
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