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.
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenAICompatibleErrors
6
+ module Normalizer
7
+ MAX_BODY_BYTES = 65_536
8
+ MAX_IDENTIFIER_BYTES = 256
9
+ UNSET = Object.new.freeze
10
+
11
+ module_function
12
+
13
+ def normalize(input, status: nil, headers: nil, body: nil,
14
+ include_provider_message: false, now: Time.now, source: nil)
15
+ response_status, response_headers, response_body = response_parts(input)
16
+ status = valid_status(status) || response_status
17
+ headers = response_headers if headers.nil?
18
+ body = response_body if body.nil?
19
+
20
+ decoded = decode_body(body.nil? && input.is_a?(Hash) ? input : body)
21
+ payload = error_payload(decoded)
22
+ code = safe_identifier(first_value(:code, payload, decoded, input))
23
+ error_type = safe_identifier(first_value(:type, payload, decoded, input))
24
+ request_id = Headers.request_id(headers) ||
25
+ safe_identifier(first_value(:request_id, payload, decoded, input))
26
+ class_name = safe_class_name(input)
27
+ exception_message = exception_message(input)
28
+ category = classify(status: status, code: code, error_type: error_type,
29
+ class_name: class_name, exception_message: exception_message)
30
+ provider_message =
31
+ if include_provider_message
32
+ Redaction.redact_sensitive_text(
33
+ first_text(payload, decoded, input),
34
+ max_chars: 2_000
35
+ )
36
+ end
37
+
38
+ ApiError.new(
39
+ category: category,
40
+ source: source || detect_source(input, status),
41
+ status: status,
42
+ code: code,
43
+ type: error_type,
44
+ request_id: request_id,
45
+ retry_after_ms: Headers.retry_after_ms(headers, now: now),
46
+ provider_message: provider_message
47
+ )
48
+ end
49
+
50
+ def response_parts(input)
51
+ status = valid_status(read(input, :status_code)) || valid_status(read(input, :status))
52
+ headers = read(input, :headers)
53
+ content = first_present(read(input, :body), read(input, :content), read(input, :data))
54
+
55
+ nested_response = read(input, :response)
56
+ if nested_response && nested_response != input
57
+ status ||= valid_status(read(nested_response, :status_code)) ||
58
+ valid_status(read(nested_response, :status))
59
+ headers ||= read(nested_response, :headers)
60
+ content = first_present(content, read(nested_response, :body),
61
+ read(nested_response, :content),
62
+ read(nested_response, :data))
63
+ end
64
+
65
+ [status, headers, content]
66
+ end
67
+
68
+ def read(value, *keys)
69
+ return UNSET if value.nil?
70
+
71
+ if value.is_a?(Hash)
72
+ keys.each do |key|
73
+ return value[key] if value.key?(key)
74
+ string_key = key.to_s
75
+ return value[string_key] if value.key?(string_key)
76
+ end
77
+ return UNSET
78
+ end
79
+
80
+ keys.each do |key|
81
+ next unless value.respond_to?(key)
82
+
83
+ begin
84
+ result = value.public_send(key)
85
+ return result unless result.nil?
86
+ rescue StandardError
87
+ next
88
+ end
89
+ end
90
+ UNSET
91
+ end
92
+
93
+ def first_present(*values)
94
+ values.find { |value| value != UNSET && !value.nil? }
95
+ end
96
+
97
+ def valid_status(value)
98
+ candidate =
99
+ if value.is_a?(Integer) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass)
100
+ value
101
+ elsif value.is_a?(String) && value.bytesize <= 16 && value.strip.match?(/\A\d+\z/)
102
+ value.to_i
103
+ end
104
+ candidate && candidate.between?(100, 599) ? candidate : nil
105
+ end
106
+
107
+ def decode_body(value)
108
+ return nil if value == UNSET || value.nil?
109
+ return value if value.is_a?(Hash) || value.is_a?(Array)
110
+
111
+ if value.is_a?(String)
112
+ return nil if value.bytesize > MAX_BODY_BYTES
113
+
114
+ text = value.scrub
115
+ candidate = text.strip
116
+ return value unless candidate.start_with?("{", "[")
117
+
118
+ begin
119
+ return JSON.parse(candidate, allow_nan: false, max_nesting: 100)
120
+ rescue JSON::ParserError, ArgumentError, SystemStackError
121
+ return value
122
+ end
123
+ end
124
+
125
+ if value.respond_to?(:to_hash)
126
+ begin
127
+ converted = value.to_hash
128
+ return converted if converted.is_a?(Hash)
129
+ rescue StandardError
130
+ return value
131
+ end
132
+ end
133
+ value
134
+ end
135
+
136
+ def error_payload(value)
137
+ return value unless value.is_a?(Hash)
138
+
139
+ nested = value[:response] || value["response"]
140
+ if nested.is_a?(Hash)
141
+ nested_error = nested[:error] || nested["error"]
142
+ return nested_error unless nested_error.nil?
143
+ end
144
+ error = value[:error] || value["error"]
145
+ error.nil? ? value : error
146
+ end
147
+
148
+ def first_value(field, *values)
149
+ values.each do |value|
150
+ next unless value.is_a?(Hash)
151
+
152
+ return value[field] if value.key?(field) && !value[field].nil?
153
+ string_key = field.to_s
154
+ return value[string_key] if value.key?(string_key) && !value[string_key].nil?
155
+ end
156
+ UNSET
157
+ end
158
+
159
+ def first_text(*values)
160
+ values.each do |value|
161
+ next unless value.is_a?(Hash)
162
+
163
+ candidate = value[:message] || value["message"]
164
+ return candidate if candidate.is_a?(String) && !candidate.empty?
165
+ end
166
+ values.each do |value|
167
+ candidate = exception_message(value)
168
+ return candidate unless candidate.nil?
169
+ end
170
+ nil
171
+ end
172
+
173
+ def safe_identifier(value)
174
+ return nil unless value.is_a?(String)
175
+
176
+ candidate = value.strip
177
+ return nil if candidate.empty? || candidate.bytesize > MAX_IDENTIFIER_BYTES
178
+ return nil unless candidate.match?(/\A[A-Za-z0-9][A-Za-z0-9._:\/-]*\z/)
179
+ return nil if candidate.match?(/bearer|api[_ -]?key|secret|token/i)
180
+
181
+ candidate
182
+ end
183
+
184
+ def exception_message(value)
185
+ return nil unless value.is_a?(Exception)
186
+
187
+ begin
188
+ text = value.message
189
+ text.is_a?(String) && text.bytesize <= 2_000 ? text : nil
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+
195
+ def safe_class_name(value)
196
+ name = value.class.name
197
+ name.is_a?(String) ? name.byteslice(0, 256).to_s : ""
198
+ rescue StandardError
199
+ ""
200
+ end
201
+
202
+ def detect_source(input, status)
203
+ name = safe_class_name(input)
204
+ return :openai_sdk if name.match?(/OpenAI|APIError|RateLimitError|AuthenticationError/i)
205
+ return :httpx if name.start_with?("HTTPX::")
206
+ return :http if status
207
+
208
+ :unknown
209
+ end
210
+
211
+ def classify(status:, code:, error_type:, class_name:, exception_message:)
212
+ structured = [code, error_type, class_name].compact.join(" ").downcase
213
+ transport = [structured, exception_message].compact.join(" ").downcase
214
+
215
+ return :schema if structured.include?("apiresponsevalidationerror")
216
+ return :validation if [400, 422].include?(status)
217
+ return :conflict if status == 409
218
+ return :authentication if status == 401
219
+ return :permission if status == 403
220
+ if status == 404
221
+ return structured.match?(/model|deployment|resource/) ? :not_found : :endpoint
222
+ end
223
+ return :timeout if status == 408
224
+ return :payload_too_large if status == 413
225
+ if status == 429
226
+ return structured.match?(/insufficient[_ -]?quota|quota[_ -]?(exhausted|exceeded)|billing|credit/) ? :quota : :rate_limit
227
+ end
228
+ return :upstream if [502, 503, 504].include?(status)
229
+ return :server if status && status >= 500
230
+ return :validation if status && status >= 400
231
+
232
+ return :timeout if structured.match?(/timeout|timedout|etimedout/)
233
+ return :network if structured.match?(/connection|connecterror|proxyerror|protocolerror/)
234
+ return :aborted if transport.match?(/abort|cancel/)
235
+ return :quota if structured.match?(/insufficient[_ -]?quota|quota[_ -]?(exhausted|exceeded)|billing|credit/)
236
+ return :authentication if structured.match?(/invalid[_ -]?api[_ -]?key|authentication|unauthori[sz]ed/)
237
+ return :permission if structured.match?(/permission|forbidden|access[_ -]?denied/)
238
+ return :rate_limit if structured.match?(/rate[_ -]?limit/)
239
+ return :timeout if transport.match?(/timed?[_ -]?out|timeout|etimedout/)
240
+ return :network if transport.match?(/connection|econnreset|econnrefused|enotfound|network/)
241
+ return :schema if structured.match?(/json|schema|parse[_ -]?error|malformed|decode/)
242
+
243
+ :unknown
244
+ end
245
+ end
246
+
247
+ module_function
248
+
249
+ def normalize_error(input = nil, **options)
250
+ Normalizer.normalize(input, **options)
251
+ end
252
+
253
+ alias normalize normalize_error
254
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAICompatibleErrors
4
+ module Redaction
5
+ SENSITIVE_KEY = /(authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|secret|password|cookie|prompt|completion)/i
6
+ REDACTED = "[REDACTED]".freeze
7
+ TRUNCATED = "[TRUNCATED]".freeze
8
+
9
+ Limits = Struct.new(:max_depth, :max_nodes, :max_keys, :max_items,
10
+ :max_chars, :max_replacements, keyword_init: true) do
11
+ def initialize(max_depth: 4, max_nodes: 250, max_keys: 32, max_items: 32,
12
+ max_chars: 8_192, max_replacements: 64)
13
+ super
14
+ raise ArgumentError, "limits must be positive" unless [max_depth, max_nodes,
15
+ max_keys, max_items, max_chars, max_replacements].all? do |value|
16
+ value.is_a?(Integer) && value.positive?
17
+ end
18
+ freeze
19
+ end
20
+ end
21
+
22
+ module_function
23
+
24
+ def redact_sensitive_text(value, max_chars: 2_000)
25
+ return nil unless value.is_a?(String)
26
+
27
+ text = value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
28
+ replacements = 0
29
+ patterns = [
30
+ [/\bBearer\s+[A-Za-z0-9._~+\/=-]+/i, "Bearer #{REDACTED}"],
31
+ [/\b(?:sk(?:-proj|-ant)?|xai|ghp|github_pat|hf|r8)[_-][A-Za-z0-9_-]{8,}/, REDACTED],
32
+ [/(api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|authorization)\s*[:=]\s*[^\s,;]+/i,
33
+ "\\1=#{REDACTED}"]
34
+ ]
35
+ patterns.each do |pattern, replacement|
36
+ text = text.gsub(pattern) do
37
+ replacements += 1
38
+ if replacement.include?("\\1")
39
+ "#{Regexp.last_match(1)}=#{REDACTED}"
40
+ else
41
+ replacement
42
+ end
43
+ end
44
+ end
45
+ text = text.byteslice(0, max_chars).to_s.scrub
46
+ replacements.positive? ? text : text
47
+ rescue EncodingError
48
+ REDACTED
49
+ end
50
+
51
+ def sanitize_for_log(value, limits: Limits.new)
52
+ state = { nodes: 0, chars: 0, replacements: 0, seen: {} }
53
+ sanitize_value(value, depth: 0, limits: limits, state: state)
54
+ end
55
+
56
+ def sensitive_key?(key)
57
+ key.is_a?(String) || key.is_a?(Symbol) ? key.to_s.match?(SENSITIVE_KEY) : false
58
+ end
59
+
60
+ def sanitize_value(value, depth:, limits:, state:)
61
+ state[:nodes] += 1
62
+ return REDACTED if state[:nodes] > limits.max_nodes
63
+ return REDACTED if depth > limits.max_depth
64
+
65
+ case value
66
+ when nil, true, false, Numeric
67
+ value
68
+ when Symbol
69
+ value.to_s
70
+ when String
71
+ sanitize_string(value, limits, state)
72
+ when Exception
73
+ "[#{value.class.name || "Exception"}]"
74
+ when Hash
75
+ return REDACTED if state[:seen][value.object_id]
76
+
77
+ state[:seen][value.object_id] = true
78
+ result = {}
79
+ value.first(limits.max_keys).each do |key, item|
80
+ normalized_key = key.is_a?(String) || key.is_a?(Symbol) ? key.to_s : "[KEY]"
81
+ result[normalized_key] = if sensitive_key?(normalized_key)
82
+ state[:replacements] += 1
83
+ REDACTED
84
+ else
85
+ sanitize_value(item, depth: depth + 1,
86
+ limits: limits, state: state)
87
+ end
88
+ end
89
+ result["[TRUNCATED]"] = TRUNCATED if value.size > limits.max_keys
90
+ result
91
+ when Array
92
+ return REDACTED if state[:seen][value.object_id]
93
+
94
+ state[:seen][value.object_id] = true
95
+ result = value.first(limits.max_items).map do |item|
96
+ sanitize_value(item, depth: depth + 1, limits: limits, state: state)
97
+ end
98
+ result << TRUNCATED if value.size > limits.max_items
99
+ result
100
+ else
101
+ "[#{value.class.name || "Object"}]"
102
+ end
103
+ end
104
+
105
+ def sanitize_string(value, limits, state)
106
+ text = redact_sensitive_text(value, max_chars: limits.max_chars)
107
+ state[:chars] += text.to_s.length
108
+ if state[:chars] > limits.max_chars
109
+ state[:replacements] += 1
110
+ return REDACTED
111
+ end
112
+ text
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAICompatibleErrors
4
+ RETRY_ACTIONS = %i[retry do_not_retry manual_decision].freeze
5
+ RETRY_REASONS = %i[
6
+ transient_and_replay_safe
7
+ partial_stream_output
8
+ request_completed
9
+ conflict_requires_resolution
10
+ replay_unsafe
11
+ caller_aborted
12
+ permanent_error
13
+ attempt_budget_exhausted
14
+ time_budget_exhausted
15
+ retry_after_exceeds_budget
16
+ unclassified_error
17
+ unknown_phase
18
+ unknown_replay_safety
19
+ invalid_context
20
+ ].freeze
21
+ REQUEST_PHASES = %i[
22
+ before_send awaiting_headers http_error sse_before_output
23
+ sse_after_output completed unknown
24
+ ].freeze
25
+ REPLAY_SAFETY = %i[safe unsafe unknown].freeze
26
+ DELAY_SOURCES = %i[server backoff].freeze
27
+
28
+ class RetryContext
29
+ attr_reader :method, :phase, :replay_safety, :attempt, :elapsed_ms,
30
+ :has_stream_output
31
+
32
+ def initialize(method:, phase:, replay_safety:, attempt:, elapsed_ms:,
33
+ has_stream_output: false)
34
+ @method = method.is_a?(String) ? method.strip.upcase : ""
35
+ @phase = coerce(phase, REQUEST_PHASES, :unknown)
36
+ @replay_safety = coerce(replay_safety, REPLAY_SAFETY, :unknown)
37
+ @attempt = attempt.is_a?(Integer) && attempt.positive? ? attempt : 0
38
+ @elapsed_ms = elapsed_ms.is_a?(Integer) && elapsed_ms >= 0 ? elapsed_ms : -1
39
+ @has_stream_output = (has_stream_output == true)
40
+ freeze
41
+ end
42
+
43
+ private
44
+
45
+ def coerce(value, allowed, fallback)
46
+ candidate = value.is_a?(Symbol) ? value : value.to_s.strip.downcase.to_sym
47
+ allowed.include?(candidate) ? candidate : fallback
48
+ end
49
+ end
50
+
51
+ class RetryPolicy
52
+ attr_reader :max_attempts, :max_elapsed_ms, :base_delay_ms,
53
+ :max_delay_ms, :jitter
54
+
55
+ def initialize(max_attempts: 3, max_elapsed_ms: 30_000, base_delay_ms: 500,
56
+ max_delay_ms: 10_000, jitter: :full)
57
+ @max_attempts = positive_integer(max_attempts)
58
+ @max_elapsed_ms = positive_integer(max_elapsed_ms)
59
+ @base_delay_ms = non_negative_integer(base_delay_ms)
60
+ @max_delay_ms = positive_integer(max_delay_ms)
61
+ jitter_symbol = jitter.is_a?(Symbol) || jitter.is_a?(String) ? jitter.to_sym : :full
62
+ @jitter = %i[full none].include?(jitter_symbol) ? jitter_symbol : :full
63
+ raise ArgumentError, "max_delay_ms must be at least base_delay_ms" if @max_delay_ms < @base_delay_ms
64
+
65
+ freeze
66
+ end
67
+
68
+ private
69
+
70
+ def positive_integer(value)
71
+ raise ArgumentError, "expected a positive integer" unless value.is_a?(Integer) && value.positive?
72
+
73
+ value
74
+ end
75
+
76
+ def non_negative_integer(value)
77
+ raise ArgumentError, "expected a non-negative integer" unless value.is_a?(Integer) && value >= 0
78
+
79
+ value
80
+ end
81
+ end
82
+
83
+ class RetryPlan
84
+ attr_reader :action, :reason, :delay_ms, :delay_source
85
+
86
+ def initialize(action:, reason:, delay_ms: nil, delay_source: nil)
87
+ action_symbol = action.is_a?(Symbol) || action.is_a?(String) ? action.to_sym : nil
88
+ reason_symbol = reason.is_a?(Symbol) || reason.is_a?(String) ? reason.to_sym : nil
89
+ @action = RETRY_ACTIONS.include?(action_symbol) ? action_symbol : :manual_decision
90
+ @reason = RETRY_REASONS.include?(reason_symbol) ? reason_symbol : :invalid_context
91
+ @delay_ms = delay_ms.is_a?(Integer) && delay_ms >= 0 ? delay_ms : nil
92
+ source_symbol = delay_source.is_a?(Symbol) || delay_source.is_a?(String) ? delay_source.to_sym : nil
93
+ @delay_source = DELAY_SOURCES.include?(source_symbol) ? source_symbol : nil
94
+ freeze
95
+ end
96
+
97
+ def retry?
98
+ @action == :retry
99
+ end
100
+
101
+ def do_not_retry?
102
+ @action == :do_not_retry
103
+ end
104
+
105
+ def manual_decision?
106
+ @action == :manual_decision
107
+ end
108
+
109
+ def to_h
110
+ { action: @action, reason: @reason, delay_ms: @delay_ms,
111
+ delay_source: @delay_source }.compact.freeze
112
+ end
113
+ end
114
+
115
+ module Retry
116
+ TRANSIENT = %i[rate_limit timeout network upstream server stream].freeze
117
+ PERMANENT = %i[authentication permission quota validation not_found
118
+ payload_too_large endpoint].freeze
119
+
120
+ module_function
121
+
122
+ def decide_retry(error, context, policy: RetryPolicy.new, random: nil)
123
+ return plan(:manual_decision, :invalid_context) unless error.is_a?(ApiError)
124
+ return plan(:manual_decision, :invalid_context) unless context.is_a?(RetryContext)
125
+ return plan(:manual_decision, :invalid_context) unless policy.is_a?(RetryPolicy)
126
+ return plan(:manual_decision, :invalid_context) unless context.attempt.positive? &&
127
+ context.elapsed_ms >= 0 && !context.method.empty?
128
+
129
+ return plan(:do_not_retry, :partial_stream_output) if context.has_stream_output ||
130
+ context.phase == :sse_after_output
131
+ return plan(:do_not_retry, :request_completed) if context.phase == :completed
132
+ return plan(:do_not_retry, :caller_aborted) if error.category == :aborted
133
+ return plan(:manual_decision, :conflict_requires_resolution) if error.category == :conflict
134
+ return plan(:do_not_retry, :permanent_error) if PERMANENT.include?(error.category)
135
+ return plan(:do_not_retry, :replay_unsafe) if context.replay_safety == :unsafe
136
+ return plan(:manual_decision, :unknown_phase) if context.phase == :unknown
137
+ return plan(:manual_decision, :unknown_replay_safety) if context.replay_safety == :unknown
138
+ return plan(:manual_decision, :unclassified_error) unless TRANSIENT.include?(error.category)
139
+ return plan(:do_not_retry, :attempt_budget_exhausted) if context.attempt >= policy.max_attempts
140
+ return plan(:do_not_retry, :time_budget_exhausted) if context.elapsed_ms >= policy.max_elapsed_ms
141
+
142
+ if error.retry_after_ms
143
+ delay = error.retry_after_ms
144
+ return plan(:do_not_retry, :retry_after_exceeds_budget) if delay > policy.max_delay_ms ||
145
+ context.elapsed_ms + delay > policy.max_elapsed_ms
146
+
147
+ return RetryPlan.new(action: :retry, reason: :transient_and_replay_safe,
148
+ delay_ms: delay, delay_source: :server)
149
+ end
150
+
151
+ exponent = [context.attempt - 1, 30].min
152
+ cap = [policy.base_delay_ms * (2**exponent), policy.max_delay_ms].min
153
+ delay =
154
+ if policy.jitter == :none
155
+ cap
156
+ else
157
+ sample = random ? random.call : Kernel.rand
158
+ return plan(:manual_decision, :invalid_context) unless sample.is_a?(Numeric) &&
159
+ sample.finite? && sample >= 0 && sample <= 1
160
+
161
+ (cap * sample).round
162
+ end
163
+ return plan(:do_not_retry, :time_budget_exhausted) if context.elapsed_ms + delay > policy.max_elapsed_ms
164
+
165
+ RetryPlan.new(action: :retry, reason: :transient_and_replay_safe,
166
+ delay_ms: delay, delay_source: :backoff)
167
+ rescue StandardError
168
+ plan(:manual_decision, :invalid_context)
169
+ end
170
+
171
+ def plan(action, reason)
172
+ RetryPlan.new(action: action, reason: reason)
173
+ end
174
+ private_class_method :plan
175
+ end
176
+
177
+ module_function
178
+
179
+ def decide_retry(error, context, **options)
180
+ Retry.decide_retry(error, context, **options)
181
+ end
182
+ end