dinie-sdk-sandbox 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE +21 -0
  4. data/README.md +280 -0
  5. data/lib/dinie/generated/api_version.rb +8 -0
  6. data/lib/dinie/generated/client.rb +96 -0
  7. data/lib/dinie/generated/errors/registry.rb +40 -0
  8. data/lib/dinie/generated/events/base.rb +11 -0
  9. data/lib/dinie/generated/events/credit_offer.rb +56 -0
  10. data/lib/dinie/generated/events/customer_created.rb +42 -0
  11. data/lib/dinie/generated/events/customer_denied.rb +39 -0
  12. data/lib/dinie/generated/events/customer_kyc_updated.rb +36 -0
  13. data/lib/dinie/generated/events/customer_status.rb +48 -0
  14. data/lib/dinie/generated/events/deserializers.rb +35 -0
  15. data/lib/dinie/generated/events/loan_active.rb +35 -0
  16. data/lib/dinie/generated/events/loan_created.rb +38 -0
  17. data/lib/dinie/generated/events/loan_payment_received.rb +46 -0
  18. data/lib/dinie/generated/events/loan_processing.rb +37 -0
  19. data/lib/dinie/generated/events/loan_signature_received.rb +48 -0
  20. data/lib/dinie/generated/events/loan_status.rb +73 -0
  21. data/lib/dinie/generated/events.rb +4 -0
  22. data/lib/dinie/generated/resources/banks.rb +25 -0
  23. data/lib/dinie/generated/resources/biometrics.rb +27 -0
  24. data/lib/dinie/generated/resources/credentials.rb +56 -0
  25. data/lib/dinie/generated/resources/credit_offers.rb +59 -0
  26. data/lib/dinie/generated/resources/customers.rb +200 -0
  27. data/lib/dinie/generated/resources/loans.rb +70 -0
  28. data/lib/dinie/generated/resources/webhook_endpoints.rb +97 -0
  29. data/lib/dinie/generated/resources.rb +9 -0
  30. data/lib/dinie/generated/types/bank.rb +17 -0
  31. data/lib/dinie/generated/types/biometrics_session.rb +16 -0
  32. data/lib/dinie/generated/types/biometrics_session_exchange_response.rb +23 -0
  33. data/lib/dinie/generated/types/credential.rb +52 -0
  34. data/lib/dinie/generated/types/credit_offer.rb +62 -0
  35. data/lib/dinie/generated/types/customer.rb +46 -0
  36. data/lib/dinie/generated/types/customer_bank_account.rb +33 -0
  37. data/lib/dinie/generated/types/ids.rb +18 -0
  38. data/lib/dinie/generated/types/kyc.rb +458 -0
  39. data/lib/dinie/generated/types/kyc_attachment_response.rb +16 -0
  40. data/lib/dinie/generated/types/loan.rb +51 -0
  41. data/lib/dinie/generated/types/money.rb +4 -0
  42. data/lib/dinie/generated/types/simulation.rb +35 -0
  43. data/lib/dinie/generated/types/transaction.rb +43 -0
  44. data/lib/dinie/generated/types/webhook_endpoint.rb +52 -0
  45. data/lib/dinie/generated/types/webhook_secret_rotation.rb +17 -0
  46. data/lib/dinie/generated/types.rb +18 -0
  47. data/lib/dinie/runtime/errors.rb +295 -0
  48. data/lib/dinie/runtime/http.rb +327 -0
  49. data/lib/dinie/runtime/idempotency.rb +34 -0
  50. data/lib/dinie/runtime/logger.rb +326 -0
  51. data/lib/dinie/runtime/model.rb +162 -0
  52. data/lib/dinie/runtime/multipart.rb +77 -0
  53. data/lib/dinie/runtime/paginator.rb +164 -0
  54. data/lib/dinie/runtime/rate_limit.rb +150 -0
  55. data/lib/dinie/runtime/request_options.rb +112 -0
  56. data/lib/dinie/runtime/retry.rb +74 -0
  57. data/lib/dinie/runtime/token_manager.rb +341 -0
  58. data/lib/dinie/runtime/webhooks.rb +194 -0
  59. data/lib/dinie/version.rb +7 -0
  60. data/lib/dinie.rb +37 -0
  61. data/sig/_external/faraday.rbs +44 -0
  62. data/sig/dinie/generated/client.rbs +45 -0
  63. data/sig/dinie/generated/errors/registry.rbs +40 -0
  64. data/sig/dinie/generated/events/base.rbs +17 -0
  65. data/sig/dinie/generated/events/credit_offer.rbs +33 -0
  66. data/sig/dinie/generated/events/customer_created.rbs +27 -0
  67. data/sig/dinie/generated/events/customer_denied.rbs +25 -0
  68. data/sig/dinie/generated/events/customer_kyc_updated.rbs +21 -0
  69. data/sig/dinie/generated/events/customer_status.rbs +26 -0
  70. data/sig/dinie/generated/events/deserializers.rbs +9 -0
  71. data/sig/dinie/generated/events/loan_active.rbs +20 -0
  72. data/sig/dinie/generated/events/loan_created.rbs +23 -0
  73. data/sig/dinie/generated/events/loan_payment_received.rbs +28 -0
  74. data/sig/dinie/generated/events/loan_processing.rbs +23 -0
  75. data/sig/dinie/generated/events/loan_signature_received.rbs +30 -0
  76. data/sig/dinie/generated/events/loan_status.rbs +40 -0
  77. data/sig/dinie/generated/resources/banks.rbs +15 -0
  78. data/sig/dinie/generated/resources/credentials.rbs +21 -0
  79. data/sig/dinie/generated/resources/credit_offers.rbs +19 -0
  80. data/sig/dinie/generated/resources/customers.rbs +58 -0
  81. data/sig/dinie/generated/resources/loans.rbs +26 -0
  82. data/sig/dinie/generated/resources/webhook_endpoints.rbs +35 -0
  83. data/sig/dinie/generated/types/bank.rbs +12 -0
  84. data/sig/dinie/generated/types/biometrics_session.rbs +11 -0
  85. data/sig/dinie/generated/types/credential.rbs +26 -0
  86. data/sig/dinie/generated/types/credit_offer.rbs +24 -0
  87. data/sig/dinie/generated/types/customer.rbs +25 -0
  88. data/sig/dinie/generated/types/customer_bank_account.rbs +26 -0
  89. data/sig/dinie/generated/types/enums.rbs +66 -0
  90. data/sig/dinie/generated/types/ids.rbs +21 -0
  91. data/sig/dinie/generated/types/kyc/attachment.rbs +14 -0
  92. data/sig/dinie/generated/types/kyc/common.rbs +42 -0
  93. data/sig/dinie/generated/types/kyc/requirements.rbs +117 -0
  94. data/sig/dinie/generated/types/kyc/submitted.rbs +21 -0
  95. data/sig/dinie/generated/types/kyc/uploads.rbs +24 -0
  96. data/sig/dinie/generated/types/loan.rbs +32 -0
  97. data/sig/dinie/generated/types/money.rbs +6 -0
  98. data/sig/dinie/generated/types/simulation.rbs +28 -0
  99. data/sig/dinie/generated/types/transaction.rbs +24 -0
  100. data/sig/dinie/generated/types/webhook_endpoint.rbs +38 -0
  101. data/sig/dinie/runtime/errors.rbs +106 -0
  102. data/sig/dinie/runtime/http.rbs +59 -0
  103. data/sig/dinie/runtime/idempotency.rbs +15 -0
  104. data/sig/dinie/runtime/logger.rbs +89 -0
  105. data/sig/dinie/runtime/model.rbs +51 -0
  106. data/sig/dinie/runtime/multipart.rbs +25 -0
  107. data/sig/dinie/runtime/paginator.rbs +50 -0
  108. data/sig/dinie/runtime/rate_limit.rbs +46 -0
  109. data/sig/dinie/runtime/request_options.rbs +35 -0
  110. data/sig/dinie/runtime/retry.rbs +29 -0
  111. data/sig/dinie/runtime/token_manager.rbs +51 -0
  112. data/sig/dinie/runtime/webhooks.rbs +31 -0
  113. data/sig/dinie/version.rbs +7 -0
  114. metadata +316 -0
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dinie
4
+ # `Page` — one page of a cursor-paginated list, and the auto-pagination engine over the pages
5
+ # that follow it (architecture §11, RB8). Ports `sdk-js` `src/runtime/paginator.ts` and mirrors
6
+ # the mechanics of OpenAI Ruby's `base_page.rb`. Part of the **public surface** (`Dinie::Page`).
7
+ #
8
+ # Dinie has exactly ONE cursor scheme (inherited contract): `starting_after` (the `id` of the
9
+ # last item of the previous page) + `has_more` (the ONLY end-of-list signal — never
10
+ # `data.length == limit`, since the server may return fewer items with `has_more: true`).
11
+ #
12
+ # A resource's `list*` does the first request **eagerly** and returns a `Page` carrying the
13
+ # first page's `data`, its `has_more`, and a `fetch_page` closure that knows how to fetch the
14
+ # next page (it maps a cursor to the `starting_after` query param and calls the transport).
15
+ # The `Page` owns *when* to call the closure and *what* cursor to pass; the resource owns *how*
16
+ # the closure reaches the network — so this class stays pure and is tested with an in-memory
17
+ # fake (no `HttpClient` coupling).
18
+ #
19
+ # ── Why NOT `include Enumerable` (RB8) ──
20
+ # `Enumerable` would graft on methods that materialize EVERY page eagerly (`to_a`, `count`,
21
+ # `sort`, `min`…). A paginator must stay lazy, so we deliberately define only `#each` (+ its
22
+ # `#auto_paging_each` alias) and `#each_page`; calling either without a block returns an
23
+ # `Enumerator`, which gives `.map` / `.first` / `.lazy` on demand without eager-loading.
24
+ #
25
+ # ── Iteration modes ──
26
+ # * `#each` / `#auto_paging_each` — yield EVERY item of EVERY following page (auto-paginate).
27
+ # * `#each_page` — yield page-by-page (manual control via `#next_page?` / `#next_page`).
28
+ # * `#first(n)` — the first `n` items across pages, fetched lazily (stops as soon as it has `n`).
29
+ #
30
+ # @example auto-paginate every item
31
+ # client.customers.list(limit: 50).each { |c| puts c.id }
32
+ # @example page-by-page
33
+ # client.customers.list.each_page { |page| process(page.data) }
34
+ # @example just the first 10, lazily
35
+ # top = client.customers.list.first(10)
36
+ #
37
+ # @note Items must respond to `#id` — the next page's `starting_after` cursor is the `id` of the
38
+ # last item on the current page. Every list item in the frozen surface satisfies this.
39
+ class Page
40
+ # Items on this page — already deserialized into typed POROs by the resource's `fetch_page`.
41
+ # @return [Array<Object>]
42
+ attr_reader :data
43
+ # Whether the API reports more pages after this one — the ONLY end-of-list signal.
44
+ # @return [Boolean]
45
+ attr_reader :has_more
46
+
47
+ # Message raised by {#next_page} when there is no next page to fetch.
48
+ NO_NEXT_PAGE_MESSAGE = "No next page; guard with `#next_page?` (or check `#has_more`) first."
49
+
50
+ class << self
51
+ # Build the FIRST page by invoking `fetch_page` with no cursor (so the closure uses the
52
+ # caller's `starting_after`, if any). This is the entry point a resource's `list*` calls.
53
+ #
54
+ # @param fetch_page [#call] `->(cursor) { { data: [...], has_more: bool } }` — fetches the
55
+ # page after `cursor` (`nil` ⇒ the first page) and returns the wire envelope
56
+ # @return [Dinie::Page]
57
+ def from_fetch(fetch_page)
58
+ build(fetch_page.call(nil), fetch_page)
59
+ end
60
+
61
+ # Wrap a fetched `{ data:, has_more: }` envelope (already item-deserialized) in a `Page`.
62
+ #
63
+ # @api private
64
+ # @param envelope [Hash] `{ data: Array, has_more: Boolean }`
65
+ # @param fetch_page [#call]
66
+ # @return [Dinie::Page]
67
+ def build(envelope, fetch_page)
68
+ new(data: envelope[:data] || [], has_more: envelope[:has_more] == true, fetch_page: fetch_page)
69
+ end
70
+
71
+ # The deterministic discriminator (architecture §7.5/§11): a list endpoint is paginated **iff**
72
+ # its envelope carries `has_more`. `/banks` lacks it, so the platform resource (story 010)
73
+ # returns a flat `Array<Bank>` instead of a `Page`. Exposed here so that rule has one home.
74
+ #
75
+ # @param envelope [Object] a parsed response body
76
+ # @return [Boolean] true when the body is a paginated list envelope
77
+ def paginated?(envelope)
78
+ envelope.is_a?(Hash) && envelope.key?(:has_more)
79
+ end
80
+ end
81
+
82
+ # @param data [Array<Object>] this page's items
83
+ # @param has_more [Boolean] whether more pages follow
84
+ # @param fetch_page [#call] `->(cursor) { envelope }` for the next page
85
+ def initialize(data:, has_more:, fetch_page:)
86
+ @data = data
87
+ @has_more = has_more
88
+ @fetch_page = fetch_page
89
+ freeze
90
+ end
91
+
92
+ # Whether a next page can be fetched. Driven by `has_more`; the `!data.empty?` guard is a safety
93
+ # net — an empty page has no last item (so no cursor), so we stop rather than loop forever on a
94
+ # malformed `{ data: [], has_more: true }`.
95
+ #
96
+ # @return [Boolean]
97
+ def next_page?
98
+ @has_more && !@data.empty?
99
+ end
100
+
101
+ # Fetch the next page. The cursor is the `id` of the LAST item on this page (mapped to
102
+ # `starting_after` by the closure). Guard with {#next_page?} before calling.
103
+ #
104
+ # @return [Dinie::Page]
105
+ # @raise [Dinie::Error] when there is no next page
106
+ def next_page
107
+ raise Dinie::Error, NO_NEXT_PAGE_MESSAGE unless next_page?
108
+
109
+ self.class.build(@fetch_page.call(@data.last.id), @fetch_page)
110
+ end
111
+
112
+ # Auto-paginate: yield every item of this page, then every item of each following page, until
113
+ # `has_more` is false. Without a block, returns an `Enumerator` (`.map` / `.first` / `.lazy`).
114
+ #
115
+ # @yieldparam item [Object]
116
+ # @return [self, Enumerator]
117
+ def each(&block)
118
+ return enum_for(:each) unless block_given?
119
+
120
+ page = self
121
+ loop do
122
+ page.data.each(&block)
123
+ break unless page.next_page?
124
+
125
+ page = page.next_page
126
+ end
127
+ self
128
+ end
129
+ alias auto_paging_each each
130
+
131
+ # Yield page-by-page (manual control), following the cursor until `has_more` is false. Without
132
+ # a block, returns an `Enumerator` over the pages.
133
+ #
134
+ # @yieldparam page [Dinie::Page]
135
+ # @return [self, Enumerator]
136
+ def each_page
137
+ return enum_for(:each_page) unless block_given?
138
+
139
+ page = self
140
+ loop do
141
+ yield page
142
+ break unless page.next_page?
143
+
144
+ page = page.next_page
145
+ end
146
+ self
147
+ end
148
+
149
+ # The first `count` items across pages (or the very first item when `count` is omitted),
150
+ # fetched lazily — only as many pages as needed are requested.
151
+ #
152
+ # @param count [Integer, nil] how many items to take; omit for a single item
153
+ # @return [Object, Array<Object>]
154
+ def first(count = UNSPECIFIED)
155
+ return auto_paging_each.first if count.equal?(UNSPECIFIED)
156
+
157
+ auto_paging_each.first(count)
158
+ end
159
+
160
+ # Sentinel distinguishing `first` (one item) from `first(n)` (an array) — `nil`/`0` are valid `n`.
161
+ UNSPECIFIED = Object.new
162
+ private_constant :UNSPECIFIED
163
+ end
164
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Dinie
6
+ # The rate-limit snapshot read from the most recent response's `X-RateLimit-*` headers
7
+ # (architecture §10, RB6). Surfaced as `client.rate_limit`; `nil` until a response carries
8
+ # valid headers.
9
+ #
10
+ # `reset_at` is the SDK's **only** `Time` (RB6): every body timestamp stays an `Integer`
11
+ # epoch, but this value comes from a transport header (not the wire body), so it is
12
+ # normalized to a `Time` — mirroring the `Date` the TypeScript SDK exposes here.
13
+ #
14
+ # @example
15
+ # rl = client.rate_limit
16
+ # rl.remaining # => 87
17
+ # rl.reset_at # => 2026-06-02 12:00:30 UTC
18
+ class RateLimit
19
+ # @return [Integer] ceiling for the current window (`X-RateLimit-Limit`)
20
+ attr_reader :limit
21
+ # @return [Integer] requests left in the current window (`X-RateLimit-Remaining`)
22
+ attr_reader :remaining
23
+ # @return [Time] when the window resets (`X-RateLimit-Reset`)
24
+ attr_reader :reset_at
25
+
26
+ # @param limit [Integer]
27
+ # @param remaining [Integer]
28
+ # @param reset_at [Time]
29
+ def initialize(limit:, remaining:, reset_at:)
30
+ @limit = limit
31
+ @remaining = remaining
32
+ @reset_at = reset_at
33
+ freeze
34
+ end
35
+
36
+ # @return [Hash{Symbol => Object}]
37
+ def to_h
38
+ { limit: limit, remaining: remaining, reset_at: reset_at }
39
+ end
40
+ alias to_hash to_h
41
+
42
+ # Enable pattern matching (`case rl; in { remaining: 0 }`).
43
+ #
44
+ # @param _keys [Array<Symbol>, nil]
45
+ # @return [Hash{Symbol => Object}]
46
+ def deconstruct_keys(_keys) = to_h
47
+
48
+ # @param other [Object]
49
+ # @return [Boolean]
50
+ def ==(other)
51
+ other.is_a?(RateLimit) && other.to_h == to_h
52
+ end
53
+ alias eql? ==
54
+
55
+ # @return [Integer]
56
+ def hash
57
+ to_h.hash
58
+ end
59
+ end
60
+
61
+ module Internal
62
+ # Parses the three `X-RateLimit-*` headers off the latest response and holds the most
63
+ # recent {Dinie::RateLimit} snapshot for {HttpClient} (architecture §10). Mirrors
64
+ # `sdk-js` `rate-limit.ts` (`parseRateLimit` + `RateLimitTracker`).
65
+ #
66
+ # **All-or-nothing (decision):** {Dinie::RateLimit} has non-nullable fields, so {parse}
67
+ # returns the whole object or `nil` — never a partial. A missing/garbage header makes the
68
+ # parse `nil`, and {#update} then keeps the previous snapshot rather than clobbering a good
69
+ # value with a header-less response (such as the token call).
70
+ class RateLimitTracker
71
+ # Response header carrying the window ceiling.
72
+ LIMIT_HEADER = "x-ratelimit-limit"
73
+ # Response header carrying the requests remaining in the window.
74
+ REMAINING_HEADER = "x-ratelimit-remaining"
75
+ # Response header carrying the window reset time (epoch seconds or delta seconds).
76
+ RESET_HEADER = "x-ratelimit-reset"
77
+
78
+ # `X-RateLimit-Reset` values at or above this (seconds) are read as an absolute Unix
79
+ # epoch; anything below as a delta in seconds from now. `1e9` seconds is ~2001-09 — far
80
+ # above any plausible "seconds until reset" window yet below every real epoch.
81
+ EPOCH_THRESHOLD_SECONDS = 1_000_000_000
82
+
83
+ # @return [Dinie::RateLimit, nil] latest snapshot, or `nil` before any valid headers
84
+ def snapshot
85
+ @current
86
+ end
87
+
88
+ # Fold a response's headers into the snapshot. A header-less or garbage response leaves
89
+ # the previous snapshot untouched (does not reset it to `nil`).
90
+ #
91
+ # @param headers [#each] response headers (case-insensitive keys)
92
+ # @return [void]
93
+ def update(headers)
94
+ parsed = self.class.parse(headers)
95
+ @current = parsed unless parsed.nil?
96
+ end
97
+
98
+ # Parse the three `X-RateLimit-*` headers into a {Dinie::RateLimit}, or `nil` when any is
99
+ # absent or unparseable. Never raises.
100
+ #
101
+ # @param headers [#each] response headers
102
+ # @return [Dinie::RateLimit, nil]
103
+ def self.parse(headers)
104
+ limit = parse_count(header_value(headers, LIMIT_HEADER))
105
+ remaining = parse_count(header_value(headers, REMAINING_HEADER))
106
+ reset_at = parse_reset(header_value(headers, RESET_HEADER))
107
+ return nil if limit.nil? || remaining.nil? || reset_at.nil?
108
+
109
+ Dinie::RateLimit.new(limit: limit, remaining: remaining, reset_at: reset_at)
110
+ end
111
+
112
+ # @api private
113
+ # @return [Integer, nil]
114
+ def self.parse_count(raw)
115
+ return nil if raw.nil?
116
+
117
+ value = Integer(raw.to_s.strip, exception: false)
118
+ value && value >= 0 ? value : nil
119
+ end
120
+
121
+ # @api private
122
+ # @return [Time, nil]
123
+ def self.parse_reset(raw)
124
+ return nil if raw.nil?
125
+
126
+ seconds = Float(raw.to_s.strip, exception: false)
127
+ return nil if seconds.nil? || seconds.negative?
128
+
129
+ seconds >= EPOCH_THRESHOLD_SECONDS ? Time.at(seconds) : Time.now + seconds
130
+ end
131
+
132
+ # @api private
133
+ # Case-insensitive header lookup; first value of a repeated header.
134
+ # @return [String, nil]
135
+ def self.header_value(headers, name)
136
+ return nil unless headers.respond_to?(:each)
137
+
138
+ target = name.downcase
139
+ headers.each do |key, value|
140
+ next unless key.to_s.downcase == target
141
+
142
+ return value.is_a?(Array) ? value.first : value
143
+ end
144
+ nil
145
+ end
146
+
147
+ private_class_method :parse_count, :parse_reset, :header_value
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dinie
4
+ module Internal
5
+ # Normalized per-call options, the trailing `request_options:` Hash every public method
6
+ # accepts (architecture §12, RB5). Validates types up front and freezes; the actual
7
+ # header merge against the client defaults (where a `nil` value removes a default) and
8
+ # the timeout/retry wiring happen in the transport (story 003).
9
+ #
10
+ # @example
11
+ # Dinie::Internal::RequestOptions.coerce(timeout: 5, headers: { "x-trace" => "abc" })
12
+ class RequestOptions
13
+ # @return [Numeric, nil] per-call timeout, in seconds
14
+ attr_reader :timeout
15
+ # @return [String, nil] explicit idempotency key (overrides the auto-generated one)
16
+ attr_reader :idempotency_key
17
+ # @return [Hash{String => String, nil}, nil] per-call header overrides (a `nil` value removes a default)
18
+ attr_reader :headers
19
+ # @return [Integer, nil] per-call retry budget override
20
+ attr_reader :max_retries
21
+
22
+ # Coerce a value into a {RequestOptions}: pass an instance through, or build one from a
23
+ # Hash (string or symbol keys; `nil` → empty).
24
+ #
25
+ # @param value [RequestOptions, Hash, nil]
26
+ # @return [RequestOptions]
27
+ # @raise [ArgumentError] on an unknown key or an invalid value type
28
+ def self.coerce(value)
29
+ return value if value.is_a?(self)
30
+
31
+ new(**(value || {}).transform_keys(&:to_sym))
32
+ end
33
+
34
+ # @param timeout [Numeric, nil] seconds; must be a non-negative Numeric
35
+ # @param idempotency_key [String, nil]
36
+ # @param headers [Hash, nil] values must be String or nil
37
+ # @param max_retries [Integer, nil] must be a non-negative Integer
38
+ # @raise [ArgumentError] on an invalid value type
39
+ def initialize(timeout: nil, idempotency_key: nil, headers: nil, max_retries: nil)
40
+ @timeout = validate_timeout(timeout)
41
+ @idempotency_key = validate_string(idempotency_key, :idempotency_key)
42
+ @headers = validate_headers(headers)
43
+ @max_retries = validate_max_retries(max_retries)
44
+ freeze
45
+ end
46
+
47
+ # @return [Hash{Symbol => Object}] the normalized options
48
+ def to_h
49
+ { timeout: timeout, idempotency_key: idempotency_key, headers: headers, max_retries: max_retries }
50
+ end
51
+ alias to_hash to_h
52
+
53
+ # @param other [Object]
54
+ # @return [Boolean]
55
+ def ==(other)
56
+ other.is_a?(RequestOptions) && other.to_h == to_h
57
+ end
58
+ alias eql? ==
59
+
60
+ # @return [Integer]
61
+ def hash
62
+ to_h.hash
63
+ end
64
+
65
+ private
66
+
67
+ def validate_timeout(value)
68
+ return nil if value.nil?
69
+ unless value.is_a?(Numeric) && !value.negative?
70
+ raise ArgumentError,
71
+ "request_options[:timeout] must be a non-negative Numeric (seconds), got #{value.inspect}"
72
+ end
73
+
74
+ value
75
+ end
76
+
77
+ def validate_max_retries(value)
78
+ return nil if value.nil?
79
+ unless value.is_a?(Integer) && !value.negative?
80
+ raise ArgumentError, "request_options[:max_retries] must be a non-negative Integer, got #{value.inspect}"
81
+ end
82
+
83
+ value
84
+ end
85
+
86
+ def validate_string(value, key)
87
+ return nil if value.nil?
88
+ unless value.is_a?(String)
89
+ raise ArgumentError,
90
+ "request_options[:#{key}] must be a String, got #{value.inspect}"
91
+ end
92
+
93
+ value
94
+ end
95
+
96
+ def validate_headers(value)
97
+ return nil if value.nil?
98
+ raise ArgumentError, "request_options[:headers] must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
99
+
100
+ value.each_with_object({}) do |(key, header_value), acc|
101
+ unless header_value.nil? || header_value.is_a?(String)
102
+ raise ArgumentError,
103
+ "request_options[:headers] values must be String or nil (nil removes a default), " \
104
+ "got #{header_value.inspect} for #{key.inspect}"
105
+ end
106
+
107
+ acc[key.to_s] = header_value
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "faraday"
5
+
6
+ module Dinie
7
+ module Internal
8
+ # Retry policy — pure decision + delay functions (no I/O, no state). The retry loop itself
9
+ # — sleeping, attempt counting, the `X-Dinie-Retry-Count` header, the 401 one-shot re-auth
10
+ # — lives in {HttpClient} (architecture §10, RB15). Mirrors `sdk-js` `retry.ts` so the two
11
+ # SDKs share the exact same backoff/jitter/status-set behavior (the `comparison.md` axis).
12
+ #
13
+ # Runtime-internal: imported directly by {HttpClient}, not part of the public surface. The
14
+ # public `Retry-After` parser lives separately as {Dinie.parse_retry_after} (story 002).
15
+ module Retry
16
+ # HTTP status codes the SDK retries (V0.2 freeze decision; architecture §10).
17
+ #
18
+ # Exactly `{408, 429, 500, 502, 503, 504}`, plus timeouts/connection errors (handled by
19
+ # {retryable_network_error?}). `409` (Dinie semantic conflict) and `410` (gone) **never**
20
+ # retry; `401` is a one-shot re-auth handled in {HttpClient}, orthogonal to this set.
21
+ # `500` is safe to retry on a non-GET because the stable `X-Idempotency-Key` (minted once
22
+ # before the loop) guarantees a retry never creates a duplicate resource.
23
+ RETRYABLE_STATUS = Set[408, 429, 500, 502, 503, 504].freeze
24
+
25
+ # Initial backoff, in seconds (the `attempt = 0` base before jitter).
26
+ INITIAL_BACKOFF_SECONDS = 0.5
27
+ # Backoff ceiling, in seconds (reached at `attempt = 4`).
28
+ MAX_BACKOFF_SECONDS = 8
29
+ # Subtractive jitter fraction — the delay is reduced by up to this share.
30
+ JITTER_RATIO = 0.25
31
+
32
+ # Faraday transport errors (no HTTP response) worth retrying: a request timeout and a
33
+ # connection failure (DNS/refused/reset). Anything else propagates as a connection error.
34
+ RETRYABLE_NETWORK_ERRORS = [Faraday::TimeoutError, Faraday::ConnectionFailed].freeze
35
+
36
+ module_function
37
+
38
+ # True only for the retryable status set (`{408, 429, 500, 502, 503, 504}`).
39
+ #
40
+ # @param status [Integer]
41
+ # @return [Boolean]
42
+ def should_retry?(status)
43
+ RETRYABLE_STATUS.include?(status)
44
+ end
45
+
46
+ # Whether a thrown transport error (no HTTP response) is worth retrying: a timeout or a
47
+ # connection failure. Keys off the Faraday error class, never the message.
48
+ #
49
+ # @param error [Exception]
50
+ # @return [Boolean]
51
+ def retryable_network_error?(error)
52
+ RETRYABLE_NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
53
+ end
54
+
55
+ # Seconds to wait before the next attempt.
56
+ #
57
+ # A parseable `Retry-After` / `Retry-After-Ms` wins (already clamped to `[0, 60]` by
58
+ # {Dinie.parse_retry_after}). Otherwise: exponential backoff `min(0.5 · 2^attempt, 8) s`
59
+ # minus up to 25% subtractive jitter via `rand` (assert the band in specs, not the value).
60
+ #
61
+ # @param attempt [Integer] zero-based attempt index just completed
62
+ # @param retry_after [String, Array<String>, nil] the response `Retry-After` header
63
+ # @param retry_after_ms [String, Array<String>, nil] the response `Retry-After-Ms` header
64
+ # @return [Float] seconds to sleep
65
+ def retry_delay(attempt, retry_after: nil, retry_after_ms: nil)
66
+ from_header = Dinie.parse_retry_after(retry_after, retry_after_ms: retry_after_ms)
67
+ return from_header unless from_header.nil?
68
+
69
+ base = [INITIAL_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS].min
70
+ base * (1 - (JITTER_RATIO * rand))
71
+ end
72
+ end
73
+ end
74
+ end