Class: Dinie::Internal::HttpClient

Inherits:
Object
  • Object
show all
Defined in:
lib/dinie/runtime/http.rb

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`). 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-Type` for JSON request bodies.

"application/json"
AUTO_IDEMPOTENT_METHODS =

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

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

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

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)



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

#rate_limitDinie::RateLimit?

Latest rate-limit snapshot (read by ‘client.rate_limit`); `nil` before the first response that carried valid `X-RateLimit-*` headers.

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). ‘204`/empty bodies return `nil`; a JSON body is parsed with symbol keys; a non-JSON body is returned as raw text.

Parameters:

  • method (Symbol, String)

    HTTP method (‘:get`, `:post`, `:patch`, `:delete`, …)

  • path (String)

    bare resource path (e.g. ‘/customers`); the base URL adds `/api/v3`

  • query (Hash, nil) (defaults to: nil)

    query params (‘nil` values are dropped)

  • body (Object, nil) (defaults to: nil)

    request body (Hash/Array → JSON; String passed through)

  • idempotent (Boolean, nil) (defaults to: nil)

    force/suppress the idempotency key; ‘nil` ⇒ auto on POST/PATCH

  • request_options (Dinie::Internal::RequestOptions, Hash, nil) (defaults to: nil)

    per-call overrides

Returns:

  • (Object, nil)

    the parsed response body

Raises:



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