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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/lib/dinie/generated/api_version.rb +8 -0
- data/lib/dinie/generated/client.rb +96 -0
- data/lib/dinie/generated/errors/registry.rb +40 -0
- data/lib/dinie/generated/events/base.rb +11 -0
- data/lib/dinie/generated/events/credit_offer.rb +56 -0
- data/lib/dinie/generated/events/customer_created.rb +42 -0
- data/lib/dinie/generated/events/customer_denied.rb +39 -0
- data/lib/dinie/generated/events/customer_kyc_updated.rb +36 -0
- data/lib/dinie/generated/events/customer_status.rb +48 -0
- data/lib/dinie/generated/events/deserializers.rb +35 -0
- data/lib/dinie/generated/events/loan_active.rb +35 -0
- data/lib/dinie/generated/events/loan_created.rb +38 -0
- data/lib/dinie/generated/events/loan_payment_received.rb +46 -0
- data/lib/dinie/generated/events/loan_processing.rb +37 -0
- data/lib/dinie/generated/events/loan_signature_received.rb +48 -0
- data/lib/dinie/generated/events/loan_status.rb +73 -0
- data/lib/dinie/generated/events.rb +4 -0
- data/lib/dinie/generated/resources/banks.rb +25 -0
- data/lib/dinie/generated/resources/biometrics.rb +27 -0
- data/lib/dinie/generated/resources/credentials.rb +56 -0
- data/lib/dinie/generated/resources/credit_offers.rb +59 -0
- data/lib/dinie/generated/resources/customers.rb +200 -0
- data/lib/dinie/generated/resources/loans.rb +70 -0
- data/lib/dinie/generated/resources/webhook_endpoints.rb +97 -0
- data/lib/dinie/generated/resources.rb +9 -0
- data/lib/dinie/generated/types/bank.rb +17 -0
- data/lib/dinie/generated/types/biometrics_session.rb +16 -0
- data/lib/dinie/generated/types/biometrics_session_exchange_response.rb +23 -0
- data/lib/dinie/generated/types/credential.rb +52 -0
- data/lib/dinie/generated/types/credit_offer.rb +62 -0
- data/lib/dinie/generated/types/customer.rb +46 -0
- data/lib/dinie/generated/types/customer_bank_account.rb +33 -0
- data/lib/dinie/generated/types/ids.rb +18 -0
- data/lib/dinie/generated/types/kyc.rb +458 -0
- data/lib/dinie/generated/types/kyc_attachment_response.rb +16 -0
- data/lib/dinie/generated/types/loan.rb +51 -0
- data/lib/dinie/generated/types/money.rb +4 -0
- data/lib/dinie/generated/types/simulation.rb +35 -0
- data/lib/dinie/generated/types/transaction.rb +43 -0
- data/lib/dinie/generated/types/webhook_endpoint.rb +52 -0
- data/lib/dinie/generated/types/webhook_secret_rotation.rb +17 -0
- data/lib/dinie/generated/types.rb +18 -0
- data/lib/dinie/runtime/errors.rb +295 -0
- data/lib/dinie/runtime/http.rb +327 -0
- data/lib/dinie/runtime/idempotency.rb +34 -0
- data/lib/dinie/runtime/logger.rb +326 -0
- data/lib/dinie/runtime/model.rb +162 -0
- data/lib/dinie/runtime/multipart.rb +77 -0
- data/lib/dinie/runtime/paginator.rb +164 -0
- data/lib/dinie/runtime/rate_limit.rb +150 -0
- data/lib/dinie/runtime/request_options.rb +112 -0
- data/lib/dinie/runtime/retry.rb +74 -0
- data/lib/dinie/runtime/token_manager.rb +341 -0
- data/lib/dinie/runtime/webhooks.rb +194 -0
- data/lib/dinie/version.rb +7 -0
- data/lib/dinie.rb +37 -0
- data/sig/_external/faraday.rbs +44 -0
- data/sig/dinie/generated/client.rbs +45 -0
- data/sig/dinie/generated/errors/registry.rbs +40 -0
- data/sig/dinie/generated/events/base.rbs +17 -0
- data/sig/dinie/generated/events/credit_offer.rbs +33 -0
- data/sig/dinie/generated/events/customer_created.rbs +27 -0
- data/sig/dinie/generated/events/customer_denied.rbs +25 -0
- data/sig/dinie/generated/events/customer_kyc_updated.rbs +21 -0
- data/sig/dinie/generated/events/customer_status.rbs +26 -0
- data/sig/dinie/generated/events/deserializers.rbs +9 -0
- data/sig/dinie/generated/events/loan_active.rbs +20 -0
- data/sig/dinie/generated/events/loan_created.rbs +23 -0
- data/sig/dinie/generated/events/loan_payment_received.rbs +28 -0
- data/sig/dinie/generated/events/loan_processing.rbs +23 -0
- data/sig/dinie/generated/events/loan_signature_received.rbs +30 -0
- data/sig/dinie/generated/events/loan_status.rbs +40 -0
- data/sig/dinie/generated/resources/banks.rbs +15 -0
- data/sig/dinie/generated/resources/credentials.rbs +21 -0
- data/sig/dinie/generated/resources/credit_offers.rbs +19 -0
- data/sig/dinie/generated/resources/customers.rbs +58 -0
- data/sig/dinie/generated/resources/loans.rbs +26 -0
- data/sig/dinie/generated/resources/webhook_endpoints.rbs +35 -0
- data/sig/dinie/generated/types/bank.rbs +12 -0
- data/sig/dinie/generated/types/biometrics_session.rbs +11 -0
- data/sig/dinie/generated/types/credential.rbs +26 -0
- data/sig/dinie/generated/types/credit_offer.rbs +24 -0
- data/sig/dinie/generated/types/customer.rbs +25 -0
- data/sig/dinie/generated/types/customer_bank_account.rbs +26 -0
- data/sig/dinie/generated/types/enums.rbs +66 -0
- data/sig/dinie/generated/types/ids.rbs +21 -0
- data/sig/dinie/generated/types/kyc/attachment.rbs +14 -0
- data/sig/dinie/generated/types/kyc/common.rbs +42 -0
- data/sig/dinie/generated/types/kyc/requirements.rbs +117 -0
- data/sig/dinie/generated/types/kyc/submitted.rbs +21 -0
- data/sig/dinie/generated/types/kyc/uploads.rbs +24 -0
- data/sig/dinie/generated/types/loan.rbs +32 -0
- data/sig/dinie/generated/types/money.rbs +6 -0
- data/sig/dinie/generated/types/simulation.rbs +28 -0
- data/sig/dinie/generated/types/transaction.rbs +24 -0
- data/sig/dinie/generated/types/webhook_endpoint.rbs +38 -0
- data/sig/dinie/runtime/errors.rbs +106 -0
- data/sig/dinie/runtime/http.rbs +59 -0
- data/sig/dinie/runtime/idempotency.rbs +15 -0
- data/sig/dinie/runtime/logger.rbs +89 -0
- data/sig/dinie/runtime/model.rbs +51 -0
- data/sig/dinie/runtime/multipart.rbs +25 -0
- data/sig/dinie/runtime/paginator.rbs +50 -0
- data/sig/dinie/runtime/rate_limit.rbs +46 -0
- data/sig/dinie/runtime/request_options.rbs +35 -0
- data/sig/dinie/runtime/retry.rbs +29 -0
- data/sig/dinie/runtime/token_manager.rbs +51 -0
- data/sig/dinie/runtime/webhooks.rbs +31 -0
- data/sig/dinie/version.rbs +7 -0
- 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
|