Class: Cadenya::Core
- Inherits:
-
Object
- Object
- Cadenya::Core
- 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.
headersmay 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
-
#initialize(base_url:, auth_header:, max_retries:, defaults:, user_agent:, connection: nil, stream_transport: nil) ⇒ Core
constructor
A new instance of Core.
- #request(method, path, query: nil, body: UNSET, expects_body: true, request_options: nil) ⇒ Object
- #resolve_default(wire_name, env_var, value) ⇒ Object
- #stream_request(method, path, query: nil, body: UNSET, headers: nil, request_options: nil, cancel: nil, &on_chunk) ⇒ Object
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
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.(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) = Core.() response = send_with_retries(method, path, query, body, extra_headers: nil, stream: nil, 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
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) = Core.() # 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([:headers]) if && [: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.}" end end status, error_body = begin transport.stream( method: method, uri: uri, headers: request_headers, body: encoded_body, cancel: cancel, open_timeout: ( && [: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. 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 |