Class: Cadenya::Core

Inherits:
Object
  • Object
show all
Defined in:
lib/cadenya/core.rb

Defined Under Namespace

Classes: CancelHandle, DefaultStreamTransport

Constant Summary collapse

UNSET =

Presence sentinel: nil can be a legitimate JSON body (null), and false is a legitimate boolean body -- truthiness cannot mean "no body".

Object.new.freeze
RETRYABLE_STATUS =
[408, 409, 429, 500, 502, 503, 504].freeze
IDEMPOTENT_METHODS =

Automatic retries apply only to idempotent methods: a POST/PATCH that succeeds server-side but loses its response would be executed twice.

%i[get head put delete].freeze
REQUEST_OPTION_KEYS =

Per-call transport controls. A strict allow-list so a misspelled API parameter can never be silently swallowed as an "option".

%i[headers timeout max_retries reconnect].freeze
MAX_ERROR_BODY =

Streaming request for SSE: yields body chunks as they arrive. Chunks reach the caller's parser ONLY for 2xx responses — anything else (1xx/3xx included) is buffered as a bounded diagnostic body and raised, so application code never observes events from a response the SDK ultimately rejects. headers may carry Last-Event-ID for resumption.

65_536
STREAM_ERROR_BODY_SECONDS =

Deadline for the bounded diagnostic read of a non-2xx stream body (seconds): together with MAX_ERROR_BODY it bounds TIME and MEMORY.

10

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url:, auth_header:, max_retries:, defaults:, user_agent:, connection: nil, stream_transport: nil) ⇒ Core

Returns a new instance of Core.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/cadenya/core.rb', line 106

def initialize(base_url:, auth_header:, max_retries:, defaults:, user_agent:, connection: nil, stream_transport: nil)
  @auth_header = auth_header
  # Finite bounded integer: negatives, floats, huge values, Infinity,
  # NaN, and non-numerics all normalize (to_i alone raises
  # FloatDomainError on non-finite floats).
  @max_retries =
    if max_retries.is_a?(Numeric) && (!max_retries.is_a?(Float) || max_retries.finite?)
      max_retries.to_i.clamp(0, 10)
    else
      0
    end
  @defaults = defaults
  @user_agent = user_agent
  # No connection-wide timeout: it would also cap SSE stream lifetime.
  # Ordinary requests get a per-request timeout in perform — but only on
  # the connection Core created; a caller-supplied connection's timeout
  # policy (including for streams) is authoritative and never touched.
  # Validate the STRUCTURE once: a query/fragment/userinfo base would
  # silently corrupt routing, and Faraday drops a base path prefix for
  # root-relative request paths -- so the prefix is captured here and
  # prepended to every operation path instead. Absolute http(s) with a
  # host is required.
  parsed = begin
    URI.parse(base_url)
  rescue URI::InvalidURIError
    raise ArgumentError, "base_url #{base_url.inspect} is not a valid URL"
  end
  unless parsed.is_a?(URI::HTTP) && !parsed.host.to_s.empty?
    raise ArgumentError, "base_url #{base_url.inspect} must be an absolute http(s) URL with a host"
  end
  if parsed.userinfo || parsed.query || parsed.fragment
    raise ArgumentError, "base_url #{base_url.inspect} must not carry userinfo, query, or fragment"
  end
  @path_prefix = parsed.path.chomp("/")
  origin = "#{parsed.scheme}://#{parsed.host}#{parsed.port == parsed.default_port ? "" : ":#{parsed.port}"}"
  @origin = origin
  @owns_conn = connection.nil?
  @stream_transport = stream_transport
  @conn = connection || Faraday.new(url: origin) do |f|
    f.request :url_encoded
  end
end

Class Method Details

.validate_request_options(opts) ⇒ Object

Raises:

  • (ArgumentError)


172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/cadenya/core.rb', line 172

def self.validate_request_options(opts)
  return nil if opts.nil?
  raise ArgumentError, "request_options must be a Hash" unless opts.is_a?(Hash)

  unknown = opts.keys - REQUEST_OPTION_KEYS
  unless unknown.empty?
    raise ArgumentError,
          "unknown request_options key(s): #{unknown.map(&:inspect).join(', ')} (supported: #{REQUEST_OPTION_KEYS.map(&:inspect).join(', ')})"
  end
  if opts.key?(:headers)
    h = opts[:headers]
    raise ArgumentError, "request_options[:headers] must be a Hash of String => String" unless h.is_a?(Hash) && h.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
  end
  if opts.key?(:timeout)
    t = opts[:timeout]
    unless t.is_a?(Numeric) && !t.is_a?(Complex) && (!t.is_a?(Float) || t.finite?) && t.positive?
      raise ArgumentError, "request_options[:timeout] must be a positive finite number of seconds"
    end
  end
  if opts.key?(:reconnect) && ![true, false].include?(opts[:reconnect])
    raise ArgumentError, "request_options[:reconnect] must be true or false"
  end
  if opts.key?(:max_retries)
    r = opts[:max_retries]
    raise ArgumentError, "request_options[:max_retries] must be an Integer >= 0" unless r.is_a?(Integer) && r >= 0
  end
  opts
end

Instance Method Details

#request(method, path, query: nil, body: UNSET, expects_body: true, request_options: nil) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/cadenya/core.rb', line 201

def request(method, path, query: nil, body: UNSET, expects_body: true, request_options: nil)
  request_options = Core.validate_request_options(request_options)
  response = send_with_retries(method, path, query, body, extra_headers: nil, stream: nil, request_options: request_options)
  raise_for_status(response.status, response.body)
  raw = response.body
  # Branch on the GENERATED expectation, not the HTTP status: a void
  # method accepts 204/empty, but an output-bearing method requires a
  # JSON document -- empty/null would fabricate a resource (or an empty
  # page) outside the declared contract.
  return nil unless expects_body

  if response.status == 204 || raw.nil? || raw.strip.empty?
    raise APIResponseError.new(
      response.status,
      "HTTP #{response.status} with an empty body where a JSON response was expected"
    )
  end
  parsed = begin
    JSON.parse(raw)
  rescue JSON::ParserError
    raise APIResponseError.new(
      response.status,
      "response body is not valid JSON",
      body: raw.to_s[0, 2000]
    )
  end
  if parsed.nil?
    raise APIResponseError.new(
      response.status,
      "HTTP #{response.status} with a JSON null body where a JSON response was expected"
    )
  end
  parsed
end

#resolve_default(wire_name, env_var, value) ⇒ Object



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/cadenya/core.rb', line 149

def resolve_default(wire_name, env_var, value)
  # Presence and validity are separate: an EXPLICITLY supplied blank is
  # a configuration error and must never fall back to ambient client/
  # environment state (that could silently target another tenant/scope).
  unless value.nil?
    trimmed = value.to_s.strip
    raise ArgumentError, "#{wire_name} must not be blank" if trimmed.empty?

    return trimmed
  end

  resolved = @defaults[wire_name].to_s.strip
  resolved = ENV.fetch(env_var, "").strip if resolved.empty?
  if resolved.empty?
    raise ArgumentError, "missing #{wire_name}: pass it, set it on the client, or set #{env_var}"
  end
  resolved
end

#stream_request(method, path, query: nil, body: UNSET, headers: nil, request_options: nil, cancel: nil, &on_chunk) ⇒ Object

Raises:



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'lib/cadenya/core.rb', line 353

def stream_request(method, path, query: nil, body: UNSET, headers: nil, request_options: nil, cancel: nil, &on_chunk)
  request_options = Core.validate_request_options(request_options)
  # Caller-owned transports are NEVER silently bypassed: Faraday's
  # adapter interface exposes no mid-read cancellation handle, so a
  # client built on connection: must configure an explicit
  # stream_transport: for SSE (or omit connection:).
  transport =
    if @stream_transport
      @stream_transport
    elsif !@owns_conn
      raise ArgumentError,
            "streaming with a caller-supplied connection: requires an explicit stream_transport: " \
            "(the connection's adapter cannot expose mid-read cancellation); " \
            "pass stream_transport: alongside connection:, or omit connection:"
    else
      DefaultStreamTransport.new
    end

  path = @path_prefix + path unless @path_prefix.empty?
  uri = URI.parse(@origin + path)
  if query
    pairs = []
    query.reject { |_k, v| v.nil? }.each do |k, v|
      (v.is_a?(Array) ? v : [v]).each { |item| pairs << [k.to_s, Util.query_value(item)] }
    end
    uri.query = URI.encode_www_form(pairs) unless pairs.empty?
  end

  request_headers = headers(true)
  merge_ci = lambda do |extra|
    extra.each do |k, v|
      request_headers.delete_if { |existing, _| existing.casecmp?(k) }
      request_headers[k] = v
    end
  end
  merge_ci.call(request_options[:headers]) if request_options && request_options[:headers]
  merge_ci.call(headers) if headers

  encoded_body = nil
  unless body.equal?(UNSET)
    request_headers["Content-Type"] = "application/json"
    encoded_body = begin
      JSON.generate(Util.deep_jsonify(body))
    rescue JSON::GeneratorError => e
      raise ArgumentError, "request body is not JSON-serializable: #{e.message}"
    end
  end

  status, error_body = begin
    transport.stream(
      method: method,
      uri: uri,
      headers: request_headers,
      body: encoded_body,
      cancel: cancel,
      open_timeout: (request_options && request_options[:timeout]) || 60,
      &on_chunk
    )
  rescue APIError, APIResponseError
    raise
  rescue StandardError, IOError, EOFError => e
    # A DELIBERATE close tore the socket down: normal termination, never
    # a transport error. Anything else keeps the stable error family.
    return nil if cancel&.cancelled?

    raise APIConnectionError, e.message
  end
  return nil if cancel&.cancelled?
  raise APIConnectionError, "stream produced no HTTP status" if status.nil?

  raise_for_status(status, error_body || "") unless (200...300).cover?(status)
  nil
end