Class: Dinie::Internal::HttpClient

Inherits:
Object
  • Object
show all
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/v3 version prefix (openapi servers[0]). Resource paths are bare (/customers), so the version lives here.

Returns:

  • (String)
"https://api.dinie.com.br/api/v3"
DEFAULT_TIMEOUT_SECONDS =

Default per-request timeout, in seconds (RB17 — Faraday idiom; the TS SDK uses ms).

Returns:

  • (Integer)
30
DEFAULT_MAX_RETRIES =

Default retry budget after the first attempt.

Returns:

  • (Integer)
3
JSON_CONTENT_TYPE =

Content-Type for JSON request bodies.

Returns:

  • (String)
"application/json"
AUTO_IDEMPOTENT_METHODS =

Methods that auto-carry an X-Idempotency-Key when the caller does not say otherwise.

Returns:

  • (Array[Symbol])
%i[post patch].freeze
USER_AGENT =

User-Agent sent on every request (architecture §5.1, telemetry AC). The api-version comes from the generated constant Generated::API_VERSION (== openapi info.version).

Returns:

  • (String)
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 =

Returns:

  • (String)

Instance Method Summary collapse

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.

Parameters:

  • token_manager (#access_token, #invalidate!) —

    injected OAuth2 token source (story 004)

  • base_url (String, nil) (defaults to: nil) —

    API base URL (default DEFAULT_BASE_URL)

  • timeout (Numeric) (defaults to: DEFAULT_TIMEOUT_SECONDS) —

    per-request timeout in seconds (default DEFAULT_TIMEOUT_SECONDS)

  • max_retries (Integer) (defaults to: DEFAULT_MAX_RETRIES) —

    retry budget after the first attempt (default DEFAULT_MAX_RETRIES)

  • idempotency (Boolean) (defaults to: true) —

    auto-generate X-Idempotency-Key on POST/PATCH (default true)

  • adapter (Symbol, nil) (defaults to: nil) —

    Faraday adapter (default :net_http_persistent)

  • connection (Faraday::Connection, nil) (defaults to: nil) —

    injected connection (pool-sharing/test seam); requests use absolute URLs, so its url_prefix is irrelevant

  • sleeper (#call, nil) (defaults to: nil) —

    backoff sleep (tests inject an instant, recording spy)

  • token_manager: (Object)
  • base_url: (String, nil) (defaults to: nil)
  • timeout: (Numeric) (defaults to: DEFAULT_TIMEOUT_SECONDS)
  • max_retries: (Integer) (defaults to: DEFAULT_MAX_RETRIES)
  • idempotency: (Boolean) (defaults to: true)
  • adapter: (Symbol, nil) (defaults to: nil)
  • connection: (Object) (defaults to: nil)
  • sleeper: (Object) (defaults to: nil)


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.

Parameters:

  • headers (Hash[String, untyped])
  • overrides (Hash[String, String?], nil)

Returns:

  • (Hash[String, untyped])


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).

Parameters:

  • token (String)

Returns:

  • (Hash[String, String])


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.

Parameters:

  • adapter (Symbol, nil)

Returns:

  • (Object)


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).

Parameters:

  • response (Object)
  • force: (Class, nil) (defaults to: nil)

Returns:



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).

Parameters:

  • path (String)
  • query (Hash[untyped, untyped], nil)

Returns:

  • (String)


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.

Parameters:

  • prepared (Object)
  • token (String)
  • attempt (Integer)

Returns:

  • (Hash[String, untyped])


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.

Parameters:

  • prepared (Object)
  • headers (Hash[String, untyped])

Returns:

  • (Object)


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.options.timeout = prepared.timeout
    req.options.open_timeout = prepared.timeout
  end
end

#encode_query(query) ⇒ String

Encode query params, dropping nil values.

Parameters:

  • query (Hash[untyped, untyped], nil)

Returns:

  • (String)


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

Parameters:

  • prepared (Object)

Returns:

  • (Object)


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.

Parameters:

  • response (Object)
  • name (String)

Returns:

  • (Object)


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.

Parameters:

  • response (Object)

Returns:

  • (Object)


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).

Returns:



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).

Parameters:

  • method: (Symbol, String)
  • path: (String)
  • query: (Hash[untyped, untyped], nil) (defaults to: nil)
  • body: (Object) (defaults to: nil)
  • idempotent: (Boolean, nil) (defaults to: nil)
  • request_options: (Dinie::Internal::RequestOptions, Hash[untyped, untyped], nil) (defaults to: nil)

Returns:

  • (Object)


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
  options = RequestOptions.coerce(request_options)
  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, options),
            max_retries: options.max_retries || @max_retries,
            timeout: options.timeout || @timeout,
            header_overrides: options.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.

Parameters:

Returns:

  • (String, nil)


240
241
242
243
244
245
246
247
# File 'lib/dinie/runtime/http.rb', line 240

def resolve_idempotency_key(idempotent, method, options)
  idempotent = AUTO_IDEMPOTENT_METHODS.include?(method) if idempotent.nil?
  return nil unless idempotent
  return options.idempotency_key unless options.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].

Parameters:

  • body (Object)

Returns:

  • ([ untyped, String? ])


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

Parameters:

  • seconds (Numeric)

Returns:

  • (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.

Parameters:

  • error (Object)

Returns:



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.message)
end