openai-compatible-errors 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9efa61faaf3046bde793f560e4c317206e66bdc514f04198321791a5fb0a292
4
+ data.tar.gz: bc57fd669d5248a34328d45aebea298ce52fc8d0164ce663bc0f8b9a83d9fd58
5
+ SHA512:
6
+ metadata.gz: eef6cd4bcb3a1a34471396797d1f3d02248cbbfca85f252c0319399d8f1c58eae43c004d9fafc70cdd3e1824fbbf567b1e1b48e26329160903318cbf15c3bf27
7
+ data.tar.gz: b0fbee45f6add7e573f598c0e18a8fba8c2ac02d497c39d49a2f1ce0095690b4d07d5dc28da7a819f918e5087ba1e3bc0bddd181a3c7796fc01a2c6fd9a97fa4
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here.
4
+
5
+ ## [0.1.0] - 2026-08-09
6
+
7
+ Initial public release.
8
+
9
+ - Normalize HTTP-like hashes, response objects and common SDK exception shapes.
10
+ - Classify authentication, permission, rate-limit, quota, validation, conflict,
11
+ transport, upstream, server, schema and stream failures.
12
+ - Parse bounded Retry-After and millisecond retry hints.
13
+ - Return immutable, safe-by-default error snapshots with optional redacted
14
+ provider text.
15
+ - Plan retries only when the caller supplies replay-safety and stream-phase
16
+ evidence.
17
+ - Inspect Chat Completions and Responses Server-Sent Events incrementally
18
+ without retaining generated output.
19
+ - Provide bounded recursive log sanitization with cycle detection.
20
+
21
+ [0.1.0]: https://github.com/airouter-dev/openai-compatible-errors-ruby/releases/tag/v0.1.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,26 @@
1
+ # Contributing
2
+
3
+ ## Local validation
4
+
5
+ This gem intentionally has no runtime dependencies. With Ruby 3.0 or newer:
6
+
7
+ bundle install
8
+ bundle exec rake test
9
+ gem build openai-compatible-errors.gemspec
10
+
11
+ The tests never call an API, open a network connection, sleep, or use real
12
+ credentials. Keep fixtures synthetic and bounded.
13
+
14
+ ## Design boundaries
15
+
16
+ - normalize_error returns a snapshot; it does not raise a provider exception
17
+ again or retain a raw body.
18
+ - decide_retry returns a plan; the caller owns waiting, cancellation,
19
+ idempotency and replay.
20
+ - SSEInspector retains replay-boundary state only, never generated text.
21
+ - Redaction reduces logging risk but is not a substitute for a data-retention
22
+ policy.
23
+
24
+ Please add a regression test for every new provider shape and document whether
25
+ the shape is observed in HTTP headers, a structured body, an SDK exception, or
26
+ an SSE event.
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI ROUTER contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # openai-compatible-errors
2
+
3
+ openai-compatible-errors is a zero-runtime-dependency Ruby library for the
4
+ failure boundary around OpenAI-compatible HTTP APIs. It turns provider-specific
5
+ HTTP, Ruby SDK and JSON error shapes into a small immutable snapshot; parses
6
+ Retry-After; makes replay safety explicit before a retry; redacts bounded
7
+ diagnostics; and incrementally inspects Chat Completions or Responses
8
+ Server-Sent Events (SSE).
9
+
10
+ It does not send requests, sleep, retry automatically, buffer an entire stream,
11
+ or retain a raw provider response. The caller remains responsible for
12
+ idempotency, cancellation, budget accounting and request replay.
13
+
14
+ ## Install
15
+
16
+ gem "openai-compatible-errors"
17
+
18
+ Then:
19
+
20
+ bundle install
21
+
22
+ Ruby 3.0 or newer is supported. The gem has no runtime dependencies, so it can
23
+ sit at the boundary of a Net::HTTP, Faraday, HTTPX or OpenAI-style client
24
+ without forcing a transport choice.
25
+
26
+ ## Normalize a failure safely
27
+
28
+ The normalizer accepts a response-like hash, a response object exposing
29
+ status/status_code, headers and body, or an exception carrying a nested
30
+ response. Provider-controlled text is not copied into the result by default.
31
+
32
+ require "openai_compatible_errors"
33
+
34
+ error = OpenAICompatibleErrors.normalize_error(
35
+ status: 429,
36
+ headers: {
37
+ "retry-after" => "2",
38
+ "x-request-id" => "req_demo_01"
39
+ },
40
+ body: {
41
+ "error" => {
42
+ "type" => "requests",
43
+ "code" => "rate_limit_exceeded",
44
+ "message" => "provider detail"
45
+ }
46
+ }
47
+ )
48
+
49
+ error.category #=> :rate_limit
50
+ error.status #=> 429
51
+ error.retry_after_ms #=> 2000
52
+ error.request_id #=> "req_demo_01"
53
+ error.provider_message #=> nil
54
+
55
+ logger.warn(error.to_log_h)
56
+
57
+ The returned ApiError has stable library-owned message text and only validated
58
+ identifiers. It never has a body, headers, exception cause, traceback, prompt
59
+ or generated-output field. If an operator genuinely needs provider text, opt in
60
+ explicitly; common bearer and API-key formats are still redacted and the value
61
+ is bounded:
62
+
63
+ diagnostic = OpenAICompatibleErrors.normalize_error(
64
+ exception,
65
+ include_provider_message: true
66
+ )
67
+ logger.warn(diagnostic.to_log_h(include_provider_message: true))
68
+
69
+ The opt-in is a risk reduction, not a guarantee that arbitrary provider text is
70
+ appropriate for a production log.
71
+
72
+ ### HTTP and SDK adapters without dependencies
73
+
74
+ No client gem is required at runtime. A Net::HTTP response works directly:
75
+
76
+ response = Net::HTTP::Post.new("/v1/responses")
77
+ # ... perform the request ...
78
+ error = OpenAICompatibleErrors.normalize_error(response)
79
+
80
+ For a client whose response object uses different names, pass the boundary
81
+ explicitly:
82
+
83
+ error = OpenAICompatibleErrors.normalize_error(
84
+ exception,
85
+ status: response.status.to_i,
86
+ headers: response.each_header.to_h,
87
+ body: response.body
88
+ )
89
+
90
+ Classification prefers HTTP status, structured error.code/error.type and
91
+ exception class names. Free-form exception messages are not retained and are
92
+ only a last-resort signal for transport categories.
93
+
94
+ Supported categories are authentication, permission, rate_limit, quota,
95
+ conflict, validation, not_found, payload_too_large, timeout, network,
96
+ upstream, server, schema, endpoint, aborted, stream and unknown. A 409
97
+ conflict intentionally does not become an automatic retry: the library cannot
98
+ infer how the application should resolve state.
99
+
100
+ ## Plan retries, never replay blindly
101
+
102
+ An HTTP method does not prove that a request is safe to replay. Supply the
103
+ operation's replay contract and the phase in which it failed:
104
+
105
+ context = OpenAICompatibleErrors::RetryContext.new(
106
+ method: "POST",
107
+ phase: :http_error,
108
+ replay_safety: :safe,
109
+ # caller-owned operation contract, not a guess from the verb
110
+ attempt: 1,
111
+ elapsed_ms: 350
112
+ )
113
+
114
+ plan = OpenAICompatibleErrors.decide_retry(error, context)
115
+
116
+ case plan.action
117
+ when :retry
118
+ schedule_retry_after(plan.delay_ms || 0) # your scheduler owns the wait
119
+ when :do_not_retry
120
+ fail_request(error)
121
+ when :manual_decision
122
+ ask_the_application_for_more_evidence
123
+ end
124
+
125
+ retry is returned only for a transient category, known replay-safe operation,
126
+ known phase, no observed stream output and remaining attempt/time budgets.
127
+ do_not_retry covers permanent failures, unsafe replay, cancellation,
128
+ completion, partial output and exhausted budgets. manual_decision means the
129
+ evidence is incomplete or unclassified.
130
+
131
+ Server Retry-After and millisecond hints are parsed without network calls.
132
+ Duplicate hints use the longest valid delay. A malformed present hint becomes a
133
+ bounded sentinel, so the default policy fails closed rather than replacing a
134
+ server instruction with a short local retry. Local exponential backoff supports
135
+ full jitter and an injectable random function for deterministic tests:
136
+
137
+ policy = OpenAICompatibleErrors::RetryPolicy.new(
138
+ max_attempts: 4,
139
+ max_elapsed_ms: 20_000,
140
+ jitter: :none
141
+ )
142
+ plan = OpenAICompatibleErrors.decide_retry(error, context, policy: policy)
143
+
144
+ The library never sleeps, opens a socket, calls a provider or replays a
145
+ request.
146
+
147
+ ## Inspect streaming replay boundaries
148
+
149
+ SSEInspector consumes byte chunks incrementally. It handles CRLF/LF framing,
150
+ UTF-8 split across network chunks, Chat Completions deltas, Responses event
151
+ names, [DONE], provider error events and unexpected EOF. It records state only:
152
+
153
+ inspector = OpenAICompatibleErrors::SSEInspector.new
154
+
155
+ response.each_body do |chunk|
156
+ inspector.feed(chunk)
157
+ consume_chunk(chunk) # the application decides what to render
158
+ end
159
+
160
+ state = inspector.close
161
+ if state.unexpected_eof? && state.has_output
162
+ # The provider may already have produced billable/user-visible output.
163
+ raise "stream ended after partial output; do not replay automatically"
164
+ end
165
+
166
+ For an Enumerable, the helper preserves one-at-a-time iteration and yields
167
+ each original chunk before asking for the next:
168
+
169
+ state = OpenAICompatibleErrors::SSEInspector.inspect_each(response_enum) do |chunk|
170
+ render(chunk)
171
+ end
172
+
173
+ Terminal states are done, incomplete, error and unexpected_eof. has_output is
174
+ intentionally conservative: a false positive merely prevents an unsafe replay,
175
+ while a false negative could duplicate output or billing. The inspector never
176
+ stores generated text or a complete response body.
177
+
178
+ ## Bounded log sanitization
179
+
180
+ Use sanitize_for_log for small diagnostic context, not as a data-retention
181
+ policy:
182
+
183
+ safe = OpenAICompatibleErrors.sanitize_for_log(
184
+ { "provider" => "example", "api_key" => ENV["API_KEY"], "attempt" => 2 }
185
+ )
186
+
187
+ It redacts sensitive key names and common credential formats, limits depth,
188
+ nodes, keys, items and characters, and breaks cycles. Exception messages are
189
+ never traversed. Keep real credentials, prompts, completions and customer
190
+ payloads out of fixtures and logs.
191
+
192
+ ## Compatibility and boundaries
193
+
194
+ This gem targets common OpenAI-compatible shapes used by gateways, self-hosted
195
+ routers and SDK adapters. It is not an OpenAI product, provider certification
196
+ or promise of complete parity with any vendor's proprietary event schema.
197
+ Unknown data-bearing SSE events are treated conservatively because replaying
198
+ after an unrecognized event can duplicate visible output.
199
+
200
+ Use a full resilience library when you need circuit breaking, cancellation-aware
201
+ sleep, hedging or request execution. Keep a provider SDK's native exception when
202
+ one stable provider contract is all your application needs.
203
+
204
+ ## Development
205
+
206
+ bundle install
207
+ bundle exec rake test
208
+ gem build openai-compatible-errors.gemspec
209
+
210
+ See CONTRIBUTING.md, SECURITY.md and RELEASING.md for the test boundary and
211
+ token-free release process.
212
+
213
+ MIT licensed.
data/RELEASING.md ADDED
@@ -0,0 +1,34 @@
1
+ # Releasing
2
+
3
+ This repository uses RubyGems.org Trusted Publishing. The release workflow
4
+ does not store a long-lived RubyGems API key.
5
+
6
+ ## One-time RubyGems setup
7
+
8
+ 1. Sign in to RubyGems.org and open
9
+ https://rubygems.org/profile/oidc/pending_trusted_publishers.
10
+ 2. Create a pending publisher with:
11
+ - Gem name: openai-compatible-errors
12
+ - GitHub repository owner: airouter-dev
13
+ - GitHub repository name: openai-compatible-errors-ruby
14
+ - Workflow filename: release.yml
15
+ - Environment: release
16
+ 3. In the GitHub repository, ensure the release environment exists. The
17
+ workflow requests id-token: write only for the publishing job.
18
+
19
+ RubyGems converts the pending publisher into a normal publisher after the first
20
+ successful push and adds the account that created it as a gem owner.
21
+
22
+ ## Versioned release
23
+
24
+ Update VERSION and CHANGELOG.md, run the complete test suite, commit the
25
+ change, and create a matching tag:
26
+
27
+ bundle exec rake test
28
+ git tag -a v0.1.0 -m "Release v0.1.0"
29
+ git push origin main --follow-tags
30
+
31
+ The tag starts .github/workflows/release.yml. The action configures a
32
+ short-lived OIDC credential, runs the Bundler release task and waits for the
33
+ gem to become available. Never paste a RubyGems token into source, CI logs or
34
+ chat.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |task|
7
+ task.libs << "lib"
8
+ task.libs << "test"
9
+ task.pattern = "test/test_*.rb"
10
+ task.verbose = true
11
+ end
12
+
13
+ task default: :test
data/SECURITY.md ADDED
@@ -0,0 +1,18 @@
1
+ # Security policy
2
+
3
+ Do not put API keys, prompts, completions, customer payloads or production
4
+ tracebacks in issues or pull requests.
5
+
6
+ The library is safe by default in one important way: ApiError has no raw body
7
+ or headers field, and provider text is omitted unless the caller explicitly
8
+ opts in. sanitize_for_log is bounded and cycle-aware, but no redactor can
9
+ guarantee that arbitrary application data is safe to log.
10
+
11
+ Report a suspected credential leak, parser denial of service, unsafe replay
12
+ decision or release-workflow issue through a private GitHub security advisory
13
+ at:
14
+
15
+ https://github.com/airouter-dev/openai-compatible-errors-ruby/security/advisories/new
16
+
17
+ Please include a minimal synthetic reproduction, affected version and impact.
18
+ Allow time for a fix before publishing details.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "openai_compatible_errors"
5
+
6
+ # This example uses a synthetic response so it never contacts a provider.
7
+ response = {
8
+ status: 429,
9
+ headers: {
10
+ "retry-after" => "1",
11
+ "x-request-id" => "req_example"
12
+ },
13
+ body: {
14
+ error: {
15
+ code: "rate_limit_exceeded",
16
+ type: "requests",
17
+ message: "provider-controlled detail"
18
+ }
19
+ }
20
+ }
21
+
22
+ error = OpenAICompatibleErrors.normalize_error(response)
23
+ Logger.new($stdout).warn(error.to_log_h)
24
+
25
+ context = OpenAICompatibleErrors::RetryContext.new(
26
+ method: "POST",
27
+ phase: :http_error,
28
+ replay_safety: :safe,
29
+ attempt: 1,
30
+ elapsed_ms: 120
31
+ )
32
+ puts OpenAICompatibleErrors.decide_retry(error, context).to_h
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAICompatibleErrors
4
+ CATEGORIES = %i[
5
+ authentication
6
+ permission
7
+ rate_limit
8
+ quota
9
+ conflict
10
+ validation
11
+ not_found
12
+ payload_too_large
13
+ timeout
14
+ network
15
+ upstream
16
+ server
17
+ schema
18
+ endpoint
19
+ aborted
20
+ stream
21
+ unknown
22
+ ].freeze
23
+
24
+ SOURCES = %i[http httpx openai_sdk sse unknown].freeze
25
+
26
+ SAFE_MESSAGES = {
27
+ authentication: "The API request could not be authenticated.",
28
+ permission: "The API request was not permitted.",
29
+ rate_limit: "The API rate limit was reached.",
30
+ quota: "The API quota is unavailable or exhausted.",
31
+ conflict: "The API request conflicted with current server state.",
32
+ validation: "The API request was rejected as invalid.",
33
+ not_found: "The requested API resource was not found.",
34
+ payload_too_large: "The API request payload was too large.",
35
+ timeout: "The API request timed out.",
36
+ network: "The API request failed at the network boundary.",
37
+ upstream: "The upstream API was temporarily unavailable.",
38
+ server: "The API returned a server error.",
39
+ schema: "The API payload did not match the expected schema.",
40
+ endpoint: "The API endpoint was not found.",
41
+ aborted: "The API request was cancelled by the caller.",
42
+ stream: "The API stream ended with an error.",
43
+ unknown: "The API request failed for an unclassified reason."
44
+ }.freeze
45
+
46
+ # A deliberately small immutable snapshot. Raw bodies, headers, causes and
47
+ # tracebacks are not retained, so logging this object is safe by default.
48
+ class ApiError
49
+ attr_reader :category, :source, :status, :code, :type, :request_id,
50
+ :retry_after_ms, :provider_message
51
+
52
+ def initialize(category:, source:, status: nil, code: nil, type: nil,
53
+ request_id: nil, retry_after_ms: nil, provider_message: nil)
54
+ @category = normalize_symbol(category, CATEGORIES, :unknown)
55
+ @source = normalize_symbol(source, SOURCES, :unknown)
56
+ @status = normalize_status(status)
57
+ @code = bounded_text(code, 256)
58
+ @type = bounded_text(type, 256)
59
+ @request_id = bounded_text(request_id, 256)
60
+ @retry_after_ms = normalize_delay(retry_after_ms)
61
+ @provider_message = bounded_text(provider_message, 2_000)
62
+ freeze
63
+ end
64
+
65
+ def message
66
+ SAFE_MESSAGES.fetch(@category)
67
+ end
68
+
69
+ def to_h(include_provider_message: false)
70
+ result = {
71
+ message: message,
72
+ category: @category,
73
+ source: @source
74
+ }
75
+ { status: @status, code: @code, type: @type, request_id: @request_id,
76
+ retry_after_ms: @retry_after_ms }.each do |key, value|
77
+ result[key] = value unless value.nil?
78
+ end
79
+ if include_provider_message && !@provider_message.nil?
80
+ result[:provider_message] = @provider_message
81
+ end
82
+ result.freeze
83
+ end
84
+
85
+ alias to_log_h to_h
86
+
87
+ def retryable_category?
88
+ %i[rate_limit timeout network upstream server stream].include?(@category)
89
+ end
90
+
91
+ def ==(other)
92
+ other.is_a?(ApiError) &&
93
+ [@category, @source, @status, @code, @type, @request_id,
94
+ @retry_after_ms, @provider_message] ==
95
+ [other.category, other.source, other.status, other.code, other.type,
96
+ other.request_id, other.retry_after_ms, other.provider_message]
97
+ end
98
+
99
+ alias eql? ==
100
+
101
+ def hash
102
+ [@category, @source, @status, @code, @type, @request_id,
103
+ @retry_after_ms, @provider_message].hash
104
+ end
105
+
106
+ def inspect
107
+ "#<#{self.class} category=#{@category.inspect} source=#{@source.inspect} " \
108
+ "status=#{@status.inspect} code=#{@code.inspect} type=#{@type.inspect} " \
109
+ "request_id=#{@request_id.inspect} retry_after_ms=#{@retry_after_ms.inspect}>"
110
+ end
111
+
112
+ def to_s
113
+ message
114
+ end
115
+
116
+ private
117
+
118
+ def normalize_symbol(value, allowed, fallback)
119
+ candidate =
120
+ if value.is_a?(Symbol)
121
+ value
122
+ elsif value.is_a?(String) && value.bytesize <= 64
123
+ value.strip.downcase.to_sym
124
+ end
125
+ allowed.include?(candidate) ? candidate : fallback
126
+ end
127
+
128
+ def normalize_status(value)
129
+ candidate =
130
+ if value.is_a?(Integer) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass)
131
+ value
132
+ elsif value.is_a?(String) && value.bytesize <= 16 && value.strip.match?(/\A\d+\z/)
133
+ value.to_i
134
+ end
135
+ candidate && candidate.between?(100, 599) ? candidate : nil
136
+ end
137
+
138
+ def normalize_delay(value)
139
+ return nil unless value.is_a?(Integer) && !value.is_a?(TrueClass) &&
140
+ !value.is_a?(FalseClass) && value >= 0
141
+
142
+ [value, Headers::MAX_RETRY_AFTER_MS].min
143
+ rescue NameError
144
+ [value, 86_400_000].min
145
+ end
146
+
147
+ def bounded_text(value, limit)
148
+ return nil unless value.is_a?(String)
149
+
150
+ value = value.strip
151
+ return nil if value.empty?
152
+
153
+ result = value.bytesize > limit ? value.byteslice(0, limit).scrub : value
154
+ result.dup.freeze
155
+ end
156
+ end
157
+
158
+ NormalizedError = ApiError
159
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenAICompatibleErrors
6
+ module Headers
7
+ MAX_RETRY_AFTER_MS = 86_400_000
8
+ MAX_HEADER_VALUE_CHARS = 512
9
+ REQUEST_ID_NAMES = %w[x-request-id request-id x-correlation-id trace-id].freeze
10
+ RETRY_NAMES = %w[retry-after retry-after-ms x-retry-after-ms].freeze
11
+
12
+ module_function
13
+
14
+ def materialize(input)
15
+ pairs = []
16
+ if input.is_a?(Hash)
17
+ input.each_pair { |key, value| pairs << [key, value] }
18
+ elsif input.respond_to?(:each_header)
19
+ begin
20
+ input.each_header { |key, value| pairs << [key, value] }
21
+ rescue StandardError
22
+ pairs = []
23
+ end
24
+ elsif input.respond_to?(:each_pair)
25
+ begin
26
+ input.each_pair { |key, value| pairs << [key, value] }
27
+ rescue StandardError
28
+ pairs = []
29
+ end
30
+ end
31
+
32
+ result = Hash.new { |hash, key| hash[key] = [] }
33
+ pairs.each do |key, value|
34
+ name = key.to_s.strip.downcase
35
+ next unless name.match?(/\A[a-z0-9][a-z0-9_-]{0,127}\z/)
36
+
37
+ values = value.is_a?(Array) ? value : [value]
38
+ values.each do |item|
39
+ text = item.is_a?(String) ? item : item.to_s
40
+ next if text.empty? || text.bytesize > MAX_HEADER_VALUE_CHARS
41
+
42
+ result[name] << text
43
+ end
44
+ end
45
+ result
46
+ end
47
+
48
+ def values(input, name)
49
+ materialize(input)[name.to_s.downcase]
50
+ end
51
+
52
+ def request_id(input)
53
+ headers = materialize(input)
54
+ REQUEST_ID_NAMES.each do |name|
55
+ headers[name].each do |candidate|
56
+ value = candidate.strip
57
+ next unless value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:\/-]{0,255}\z/)
58
+ next if value.match?(/bearer|api[_ -]?key|secret|token/i)
59
+
60
+ return value
61
+ end
62
+ end
63
+ nil
64
+ end
65
+
66
+ # Returns a bounded delay. A malformed present hint returns the maximum
67
+ # sentinel so a conservative retry policy fails closed.
68
+ def retry_after_ms(input, now: Time.now)
69
+ headers = materialize(input)
70
+ candidates = []
71
+ malformed = false
72
+ RETRY_NAMES.each do |name|
73
+ headers[name].each do |value|
74
+ parsed =
75
+ if name.end_with?("-ms")
76
+ parse_milliseconds(value)
77
+ else
78
+ parse_retry_after(value, now: now)
79
+ end
80
+ if parsed.nil?
81
+ malformed = true
82
+ else
83
+ candidates << parsed
84
+ end
85
+ end
86
+ end
87
+ return candidates.max unless candidates.empty?
88
+ malformed ? MAX_RETRY_AFTER_MS : nil
89
+ end
90
+
91
+ def parse_milliseconds(value)
92
+ return nil unless value.match?(/\A\d{1,12}\z/)
93
+
94
+ [value.to_i, MAX_RETRY_AFTER_MS].min
95
+ end
96
+
97
+ def parse_retry_after(value, now:)
98
+ if value.match?(/\A\d{1,8}\z/)
99
+ return [value.to_i * 1_000, MAX_RETRY_AFTER_MS].min
100
+ end
101
+ return nil unless value.bytesize <= 128
102
+
103
+ begin
104
+ seconds = Time.httpdate(value).to_f - now.to_f
105
+ return [[(seconds * 1_000).ceil, 0].max, MAX_RETRY_AFTER_MS].min
106
+ rescue ArgumentError
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end