Class: Dinie::Internal::HttpClient
- Inherits:
-
Object
- Object
- Dinie::Internal::HttpClient
- Defined in:
- lib/dinie/runtime/http.rb,
sig/dinie/runtime/http.rbs
Overview
HttpClient — the request-lifecycle orchestrator and heart of the runtime (architecture
§10, RB15), porting sdk-js src/runtime/http.ts. It owns one Faraday::Connection per
Client (adapter :net_http_persistent — a real connection pool with keep-alive,
thread-safe) and, for every logical request, runs the full lifecycle:
1. mint an `X-Idempotency-Key` ONCE, before the loop, for non-GET writes, so every
retry of the same logical request reuses it (never a duplicate resource);
2. obtain a Bearer token from the injected `TokenManager`;
3. assemble headers (auth, telemetry, idempotency, retry-count, content-type);
4. dispatch through Faraday — **one round-trip per call**; the retry loop is ours;
5. fold `X-RateLimit-*` headers into the snapshot `client.rate_limit` reads;
6. on success (< 300), parse and return the raw body (resources deserialize);
7. on 401, run the one-shot re-auth (`token_manager.invalidate!` + a fresh token), then
give up with `AuthError` if a second 401 follows (no loop);
8. on a retryable status (`{408,429,500,502,503,504}`) or a transient transport error,
back off (`Retry.retry_delay`) and retry while attempts remain, bumping
`X-Dinie-Retry-Count`;
9. otherwise map the response to a typed error via {Errors.from_response}.
── DI seam (architecture §10, RB15) ──
The TokenManager is injected (HttpClient.new(token_manager:, …)). Story 004 builds
the real, concurrency-safe one; specs inject a fake. auth_headers is token_manager .access_token; the 401 one-shot is token_manager.invalidate!. No global/singleton, no
placeholder hack — this is what lets with_options (story 004) clone a client while
sharing the same token cache.
── controlled runtime → generated seam (openapi-SoT forcing function, architecture §4) ──
The rule is "runtime/ never imports generated/". This module is one declared exception:
it references AuthError (forced on a persistent 401) and dispatches non-2xx
responses through Errors.from_response, which reads the generated
ERROR_REGISTRY. The reference is the forcing function — an error not in
openapi.yaml is not in generated/, so there is nowhere to dispatch it.
Runtime-internal: Client and the resources construct and call it; it is not part of the public SDK surface.
The ClassLength cop is disabled below: the request lifecycle (prepare → loop → dispatch →
error/retry/re-auth → parse) is one cohesive orchestrator; splitting it across classes would
scatter the lifecycle and obscure the parity with sdk-js http.ts.
Constant Summary collapse
- DEFAULT_BASE_URL =
Default production API base URL — carries the
/api/v3version prefix (openapiservers[0]). Resource paths are bare (/customers), so the version lives here. "https://api.dinie.com.br/api/v3"- DEFAULT_TIMEOUT_SECONDS =
Default per-request timeout, in seconds (RB17 — Faraday idiom; the TS SDK uses ms).
30- DEFAULT_MAX_RETRIES =
Default retry budget after the first attempt.
3- JSON_CONTENT_TYPE =
Content-Typefor JSON request bodies. "application/json"- AUTO_IDEMPOTENT_METHODS =
Methods that auto-carry an
X-Idempotency-Keywhen the caller does not say otherwise. %i[post patch].freeze
- USER_AGENT =
User-Agentsent on every request (architecture §5.1, telemetry AC). The api-version comes from the generated constant Generated::API_VERSION (== openapi info.version). format( "Dinie-SDK-Ruby/%<sdk>s (api-version=%<api>s; ruby/%<rt>s)", sdk: Dinie::VERSION, api: Dinie::Generated::API_VERSION, rt: RUBY_VERSION ).freeze
- API_VERSION =
Instance Method Summary collapse
-
#apply_header_overrides(headers, overrides) ⇒ Hash[String, untyped]
Apply per-call header overrides:
nilremoves a default, any String replaces it. -
#base_headers(token) ⇒ Hash[String, String]
Auth + telemetry defaults present on every request (architecture §5.1).
-
#build_connection(adapter) ⇒ Object
Standalone fallback connection, used only when no
connection:is injected (tests, direct use). -
#build_error(response, force: nil) ⇒ Dinie::APIStatusError
Build the typed error for a non-2xx response (story 002 dispatch).
-
#build_full_url(path, query) ⇒ String
Prepend the base URL and append the query string.
-
#build_headers(prepared, token, attempt) ⇒ Hash[String, untyped]
Assemble the outgoing headers: Bearer auth, telemetry, the Idempotency-Key (when present),
X-Dinie-Retry-Counton retries, andContent-Typewhen there is a body. -
#dispatch(prepared, headers) ⇒ Object
One Faraday round-trip.
-
#encode_query(query) ⇒ String
Encode query params, dropping
nilvalues. -
#execute(prepared) ⇒ Object
The retry loop.
-
#header(response, name) ⇒ Object
First value of a (possibly repeated) response header.
-
#initialize(token_manager:, base_url: nil, timeout: DEFAULT_TIMEOUT_SECONDS, max_retries: DEFAULT_MAX_RETRIES, idempotency: true, adapter: nil, connection: nil, sleeper: nil) ⇒ HttpClient
constructor
A new instance of HttpClient.
-
#parse_body(response) ⇒ Object
Read + parse a successful response body.
-
#rate_limit ⇒ Dinie::RateLimit?
Latest rate-limit snapshot (read by
client.rate_limit). -
#request(method:, path:, query: nil, body: nil, idempotent: nil, request_options: nil) ⇒ Object
Run one logical request end-to-end and return the parsed body (resources deserialize it).
-
#resolve_idempotency_key(idempotent, method, options) ⇒ String?
Resolve the
X-Idempotency-Key(architecture §10). -
#serialize_body(body) ⇒ [ untyped, String? ]
Serialize a request body: a Multipart becomes a Faraday multipart body tagged with a bare
multipart/form-datacontent type (Faraday's:multipartmiddleware appends the boundary and encodes it — story 009); a Hash/Array → JSON; a String is assumed already-serialized JSON;nilsends no body. - #sleep_for(seconds) ⇒ Object
-
#translate_network_error(error) ⇒ Dinie::APIError
Map an exhausted/non-retryable transport error to the right connection-error class.
Constructor Details
#initialize(token_manager:, base_url: nil, timeout: DEFAULT_TIMEOUT_SECONDS, max_retries: DEFAULT_MAX_RETRIES, idempotency: true, adapter: nil, connection: nil, sleeper: nil) ⇒ HttpClient
Returns a new instance of HttpClient.
88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/dinie/runtime/http.rb', line 88 def initialize(token_manager:, base_url: nil, timeout: DEFAULT_TIMEOUT_SECONDS, # rubocop:disable Metrics/ParameterLists max_retries: DEFAULT_MAX_RETRIES, idempotency: true, adapter: nil, connection: nil, sleeper: nil) @token_manager = token_manager @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "") @timeout = timeout @max_retries = max_retries @idempotency = idempotency @rate_limit_tracker = RateLimitTracker.new @sleeper = sleeper || ->(seconds) { sleep(seconds) } @connection = connection || build_connection(adapter) end |
Instance Method Details
#apply_header_overrides(headers, overrides) ⇒ Hash[String, untyped]
Apply per-call header overrides: nil removes a default, any String replaces it.
223 224 225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/dinie/runtime/http.rb', line 223 def apply_header_overrides(headers, overrides) return headers if overrides.nil? overrides.each do |key, value| lower = key.to_s.downcase if value.nil? headers.delete(lower) else headers[lower] = value end end headers end |
#base_headers(token) ⇒ Hash[String, String]
Auth + telemetry defaults present on every request (architecture §5.1).
211 212 213 214 215 216 217 218 219 220 |
# File 'lib/dinie/runtime/http.rb', line 211 def base_headers(token) { "authorization" => "Bearer #{token}", "accept" => JSON_CONTENT_TYPE, "user-agent" => USER_AGENT, "x-dinie-sdk-language" => "ruby", "x-dinie-sdk-version" => Dinie::VERSION, "x-dinie-sdk-runtime" => "ruby/#{RUBY_VERSION}" } end |
#build_connection(adapter) ⇒ Object
Standalone fallback connection, used only when no connection: is injected (tests, direct
use). The composed SDK always injects the SHARED connection built by Client, which
is where the story 005 logging middleware mounts (so it also observes the TokenManager's
token POST). This builder is intentionally plain — do NOT mount logging here.
319 320 321 322 323 324 |
# File 'lib/dinie/runtime/http.rb', line 319 def build_connection(adapter) Faraday.new(url: @base_url) do |faraday| faraday.request(:multipart) faraday.adapter(adapter || :net_http_persistent) end end |
#build_error(response, force: nil) ⇒ Dinie::APIStatusError
Build the typed error for a non-2xx response (story 002 dispatch). body is the raw
String (JSON is parsed inside from_response); force skips dispatch (persistent 401).
299 300 301 302 |
# File 'lib/dinie/runtime/http.rb', line 299 def build_error(response, force: nil) Errors.from_response(status: response.status, headers: response.headers.to_h, body: response.body, force: force) end |
#build_full_url(path, query) ⇒ String
Prepend the base URL and append the query string. Absolute URLs sidestep Faraday's
leading-slash base-path replacement (the trap sdk-js handles via #basePath).
276 277 278 279 280 281 282 |
# File 'lib/dinie/runtime/http.rb', line 276 def build_full_url(path, query) url = "#{@base_url}#{path}" encoded = encode_query(query) return url if encoded.empty? "#{url}#{path.include?("?") ? "&" : "?"}#{encoded}" end |
#build_headers(prepared, token, attempt) ⇒ Hash[String, untyped]
Assemble the outgoing headers: Bearer auth, telemetry, the Idempotency-Key (when
present), X-Dinie-Retry-Count on retries, and Content-Type when there is a body. A
caller header with a nil value removes the matching default; any other value overrides.
202 203 204 205 206 207 208 |
# File 'lib/dinie/runtime/http.rb', line 202 def build_headers(prepared, token, attempt) headers = base_headers(token) headers["content-type"] = prepared.content_type unless prepared.content_type.nil? headers["x-idempotency-key"] = prepared.idempotency_key unless prepared.idempotency_key.nil? headers["x-dinie-retry-count"] = attempt.to_s if attempt.positive? apply_header_overrides(headers, prepared.header_overrides) end |
#dispatch(prepared, headers) ⇒ Object
One Faraday round-trip. Absolute URL ⇒ the connection's url_prefix is irrelevant, so
base_url='…/api/v3' never collides with a leading-slash resource path.
192 193 194 195 196 197 |
# File 'lib/dinie/runtime/http.rb', line 192 def dispatch(prepared, headers) @connection.run_request(prepared.http_method, prepared.url, prepared.body, headers) do |req| req..timeout = prepared.timeout req..open_timeout = prepared.timeout end end |
#encode_query(query) ⇒ String
Encode query params, dropping nil values.
285 286 287 288 289 |
# File 'lib/dinie/runtime/http.rb', line 285 def encode_query(query) return "" if query.nil? || query.empty? URI.encode_www_form(query.compact) end |
#execute(prepared) ⇒ Object
The retry loop. attempt increments on every continued path (a transport retry, a
status retry, AND the 401 one-shot), so X-Dinie-Retry-Count mirrors the TS SDK exactly.
rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/dinie/runtime/http.rb', line 145 def execute(prepared) attempt = 0 reauthed = false loop do token = @token_manager.access_token headers = build_headers(prepared, token, attempt) begin response = dispatch(prepared, headers) rescue Faraday::Error => e raise translate_network_error(e) unless Retry.retryable_network_error?(e) && attempt < prepared.max_retries sleep_for(Retry.retry_delay(attempt)) attempt += 1 next end @rate_limit_tracker.update(response.headers) status = response.status return parse_body(response) if status < 300 # 401 one-shot: drop the cached token, re-auth once, retry. if status == 401 && !reauthed @token_manager.invalidate! reauthed = true attempt += 1 next end # Persistent 401 after the one-shot: give up with a typed AuthError (no loop). Forcing # the class guarantees the type even if the body lacks the openapi `type` URL. raise build_error(response, force: Dinie::AuthError) if status == 401 if Retry.should_retry?(status) && attempt < prepared.max_retries sleep_for(Retry.retry_delay(attempt, retry_after: header(response, "retry-after"), retry_after_ms: header(response, "retry-after-ms"))) attempt += 1 next end raise build_error(response) end end |
#header(response, name) ⇒ Object
First value of a (possibly repeated) response header.
292 293 294 295 |
# File 'lib/dinie/runtime/http.rb', line 292 def header(response, name) value = response.headers[name] value.is_a?(Array) ? value.first : value end |
#parse_body(response) ⇒ Object
Read + parse a successful response body. 204/empty ⇒ nil; JSON ⇒ parsed with symbol
keys (resources read raw[:field]); non-JSON ⇒ raw text.
263 264 265 266 267 268 269 270 271 272 |
# File 'lib/dinie/runtime/http.rb', line 263 def parse_body(response) return nil if response.status == 204 body = response.body return nil if body.nil? || body.empty? JSON.parse(body, symbolize_names: true) rescue JSON::ParserError body end |
#rate_limit ⇒ Dinie::RateLimit?
Latest rate-limit snapshot (read by client.rate_limit).
105 106 107 |
# File 'lib/dinie/runtime/http.rb', line 105 def rate_limit @rate_limit_tracker.snapshot end |
#request(method:, path:, query: nil, body: nil, idempotent: nil, request_options: nil) ⇒ Object
Run one logical request end-to-end and return the parsed body (resources deserialize it).
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/dinie/runtime/http.rb', line 124 def request(method:, path:, query: nil, body: nil, idempotent: nil, request_options: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists = RequestOptions.coerce() normalized_method = method.to_s.downcase.to_sym serialized_body, content_type = serialize_body(body) execute(PreparedRequest.new( http_method: normalized_method, url: build_full_url(path, query), body: serialized_body, content_type: content_type, idempotency_key: resolve_idempotency_key(idempotent, normalized_method, ), max_retries: .max_retries || @max_retries, timeout: .timeout || @timeout, header_overrides: .headers )) end |
#resolve_idempotency_key(idempotent, method, options) ⇒ String?
Resolve the X-Idempotency-Key (architecture §10). A per-call override always wins —
even when auto-idempotency is opted out globally, an explicit key is an explicit opt-in.
Otherwise a key is auto-minted for POST/PATCH unless opted out. nil ⇒ send no key.
240 241 242 243 244 245 246 247 |
# File 'lib/dinie/runtime/http.rb', line 240 def resolve_idempotency_key(idempotent, method, ) idempotent = AUTO_IDEMPOTENT_METHODS.include?(method) if idempotent.nil? return nil unless idempotent return .idempotency_key unless .idempotency_key.nil? return nil unless @idempotency Idempotency.generate_key end |
#serialize_body(body) ⇒ [ untyped, String? ]
Serialize a request body: a Multipart becomes a Faraday multipart body tagged with a bare
multipart/form-data content type (Faraday's :multipart middleware appends the boundary
and encodes it — story 009); a Hash/Array → JSON; a String is assumed already-serialized
JSON; nil sends no body. Returns [body_or_nil, content_type_or_nil].
253 254 255 256 257 258 259 |
# File 'lib/dinie/runtime/http.rb', line 253 def serialize_body(body) return [nil, nil] if body.nil? return [body.to_faraday_body, Multipart::CONTENT_TYPE] if body.is_a?(Multipart) return [body, JSON_CONTENT_TYPE] if body.is_a?(String) [JSON.generate(body), JSON_CONTENT_TYPE] end |
#sleep_for(seconds) ⇒ Object
311 312 313 |
# File 'lib/dinie/runtime/http.rb', line 311 def sleep_for(seconds) @sleeper.call(seconds) end |
#translate_network_error(error) ⇒ Dinie::APIError
Map an exhausted/non-retryable transport error to the right connection-error class.
305 306 307 308 309 |
# File 'lib/dinie/runtime/http.rb', line 305 def translate_network_error(error) return Dinie::APITimeoutError.new if error.is_a?(Faraday::TimeoutError) Dinie::APIConnectionError.new(error.) end |