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 +7 -0
- data/CHANGELOG.md +21 -0
- data/CONTRIBUTING.md +26 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +213 -0
- data/RELEASING.md +34 -0
- data/Rakefile +13 -0
- data/SECURITY.md +18 -0
- data/examples/net_http.rb +32 -0
- data/lib/openai_compatible_errors/error.rb +159 -0
- data/lib/openai_compatible_errors/headers.rb +111 -0
- data/lib/openai_compatible_errors/normalize.rb +254 -0
- data/lib/openai_compatible_errors/redaction.rb +115 -0
- data/lib/openai_compatible_errors/retry.rb +182 -0
- data/lib/openai_compatible_errors/sse.rb +284 -0
- data/lib/openai_compatible_errors/version.rb +5 -0
- data/lib/openai_compatible_errors.rb +21 -0
- data/test/test_helper.rb +10 -0
- data/test/test_normalize.rb +120 -0
- data/test/test_redaction.rb +46 -0
- data/test/test_retry.rb +92 -0
- data/test/test_sse.rb +80 -0
- metadata +107 -0
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
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
|