Skip to content

Commit 3cdf0a4

Browse files
authored
feat: [CHA-2958] server-side error handling (#63)
* feat: add structured error hierarchy + wait_for_task helper (CHA-2958) Implements the Ruby half of the Server-Side SDK Error Handling Spec (§9.5). New classes under GetStreamRuby: - StreamError < StandardError (base) - ApiError < StreamError (4xx/5xx + unparseable bodies) - RateLimitError < ApiError (429 + parsed Retry-After) - TransportError < StreamError (no-response failures) - TaskError < StreamError (failed async tasks) Client#handle_response now deserializes the canonical APIError envelope (code, message, exception_fields, more_info, StatusCode, details, unrecoverable, duration) into ApiError attributes; only `message` survived previously. Client#wait_for_task(task_id, poll_interval:, timeout:) added per §8 — returns the task result on completion, raises TaskError on failure, raises TransportError(error_type: "timeout") when the deadline elapses. rescue Faraday::Error blocks now raise TransportError inside the rescue so Exception#cause carries the original Faraday error. Back-compat: GetStreamRuby::Error aliased to StreamError; GetStreamRuby::APIError (legacy caps) is a deprecated alias for ApiError with one-time Kernel.warn on first use; slated for removal in v9.0. Pre-flight multipart validation now raises ArgumentError. * fix: drop unreachable `|| {}` fallback after Hash#to_h `Hash#to_h` always returns a Hash, so the `|| {}` branch is dead. Rubocop Lint/UselessOr flagged this; behavior is unchanged. * docs(ruby): drop ticket refs and mechanism prose from error-handling comments
1 parent 2835ce1 commit 3cdf0a4

6 files changed

Lines changed: 808 additions & 35 deletions

File tree

.rubocop.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ Metrics/MethodLength:
5252
- 'lib/getstream_ruby/generated/**/*'
5353

5454
Metrics/ClassLength:
55-
Max: 200
55+
Max: 250
56+
Exclude:
57+
- 'lib/getstream_ruby/generated/**/*'
58+
59+
Metrics/ParameterLists:
60+
Max: 8 # ApiError mirrors the canonical APIError envelope.
5661
Exclude:
5762
- 'lib/getstream_ruby/generated/**/*'
5863

CHANGELOG.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
### Added
44

5+
- New error class hierarchy under `GetStreamRuby`:
6+
* `StreamError < StandardError`. Abstract base for every SDK-raised exception.
7+
* `ApiError < StreamError`. Raised on any HTTP 4xx/5xx, and on responses whose body cannot be parsed as the canonical `APIError` envelope. Exposes `status_code`, `code`, `message`, `exception_fields`, `unrecoverable`, `raw_response_body`, `more_info`, `details`. Previously only `message` survived.
8+
* `RateLimitError < ApiError`. Raised on HTTP 429. Adds `retry_after` (Float seconds, nil when the header is absent). Parses the `Retry-After` response header per RFC 7231 in both integer-seconds and HTTP-date forms. Past HTTP-dates clamp to 0.
9+
* `TransportError < StreamError`. Raised when no HTTP response is received (connection reset, timeout, TLS handshake failure, DNS failure). Exposes `error_type` from the enum `connection_reset`, `timeout`, `dns_failure`, `tls_handshake_failed`, `unknown`. Always raised inside the matching `rescue Faraday::Error` block, so `Exception#cause` is set to the underlying Faraday error.
10+
* `TaskError < StreamError`. Raised by `wait_for_task` when an async task finishes with `status="failed"`. Exposes `task_id`, `error_type`, `description`, `stack_trace`, `version`.
11+
- New `Client#wait_for_task(task_id, poll_interval: 1, timeout: 60)` helper. Polls `/api/v2/tasks/:id` and: returns the task `result` payload when status reaches `completed`; raises `TaskError` when status reaches `failed`; raises `TransportError` with `error_type: "timeout"` when the deadline elapses.
12+
- `Client#post` (and the multipart upload path) now deserialize the full canonical `APIError` envelope (`code`, `message`, `exception_fields`, `more_info`, `StatusCode`, `details`, `unrecoverable`, `duration`) and populate the new `ApiError` attributes.
13+
14+
### Changed
15+
16+
- The old `GetStreamRuby::APIError` constant remains as a deprecated alias for `GetStreamRuby::ApiError` for one minor cycle, slated for removal in v9.0. First access emits a one-time `Kernel.warn` deprecation notice.
17+
- The old `GetStreamRuby::Error` constant is preserved as an alias for `StreamError`. Existing `rescue GetStreamRuby::Error` clauses continue to match.
18+
- Pre-flight multipart validation (`file name must be provided`, `file not found`) now raises `ArgumentError` instead of the old `APIError`. These are caller-side programming errors and don't belong on the API-error surface.
19+
20+
### Webhook helpers
21+
522
- Webhook handling spec helpers (CHA-2961): `UnknownEvent` class for forward-compat;
623
`gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload` primitives;
724
`parse_event` (returns typed event or `UnknownEvent` for unrecognized discriminators);
@@ -23,10 +40,6 @@
2340
- Conformance fixture suite under `test/fixtures/webhooks/` (14 event-type buckets plus
2441
`_invalid/` negative cases).
2542

26-
### Changed
27-
28-
- No breaking changes.
29-
3043
### Fixed
3144

3245
- `event_class_for_type` now references `GetStream::Generated::Models::*Event`

lib/getstream_ruby/client.rb

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
require_relative 'extensions/moderation_extensions'
1818
require_relative 'generated/feed'
1919
require_relative 'generated/webhook'
20+
require_relative 'generated/models/api_error'
2021
require_relative 'stream_response'
22+
require_relative 'error_mapping'
2123

2224
module GetStreamRuby
2325

@@ -134,6 +136,49 @@ def post(path, body = {})
134136
request(:post, path, body)
135137
end
136138

139+
# Polls the task-status endpoint until the task reaches a terminal state.
140+
#
141+
# Behaviour:
142+
# - status="completed": returns the task `result` payload.
143+
# - status="failed": raises `TaskError` populated from the task's
144+
# `ErrorResult` (`type`, `description`, `stacktrace`,
145+
# `version`).
146+
# - timeout elapsed: raises `TransportError` with `error_type:
147+
# "timeout"`.
148+
#
149+
# @param task_id [String]
150+
# @param poll_interval [Numeric] seconds between polls (default 1)
151+
# @param timeout [Numeric] max seconds to wait (default 60)
152+
# @return [Object] the task `result` payload on success
153+
# @raise [TaskError] when the task reports `status="failed"`
154+
# @raise [TransportError] when the timeout elapses (`error_type="timeout"`)
155+
def wait_for_task(task_id, poll_interval: 1, timeout: 60)
156+
start_time = monotonic_now
157+
158+
loop do
159+
160+
response = common.get_task(task_id)
161+
status = response.status
162+
163+
case status
164+
when 'completed'
165+
return response.result
166+
when 'failed'
167+
raise ErrorMapping.build_task_error(task_id, response.error)
168+
end
169+
170+
if monotonic_now - start_time >= timeout
171+
raise TransportError.new(
172+
"wait_for_task timed out after #{timeout}s for task_id=#{task_id}",
173+
error_type: 'timeout',
174+
)
175+
end
176+
177+
sleep(poll_interval)
178+
179+
end
180+
end
181+
137182
def make_request(method, path, query_params: nil, body: nil, request_timeout: nil)
138183
# Handle query parameters
139184
if query_params && !query_params.empty?
@@ -168,7 +213,7 @@ def request(method, path, data = {}, request_timeout: nil)
168213

169214
handle_response(response)
170215
rescue Faraday::Error => e
171-
raise APIError, "Request failed: #{e.message}"
216+
raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e))
172217
end
173218

174219
def build_connection
@@ -242,29 +287,13 @@ def user_agent
242287
end
243288

244289
def handle_response(response)
245-
case response.status
246-
when 200..299
247-
StreamResponse.new(response.body)
248-
else
249-
# Parse JSON response body if it's a string
250-
parsed_body = if response.body.is_a?(String)
251-
begin
252-
JSON.parse(response.body)
253-
rescue JSON::ParserError
254-
response.body
255-
end
256-
else
257-
response.body
258-
end
259-
260-
error_message = if parsed_body.is_a?(Hash)
261-
parsed_body['message'] || parsed_body['detail'] ||
262-
"Request failed with status #{response.status}"
263-
else
264-
"Request failed with status #{response.status}"
265-
end
266-
raise APIError, error_message
267-
end
290+
return StreamResponse.new(response.body) if (200..299).cover?(response.status)
291+
292+
ErrorMapping.raise_api_error(response)
293+
end
294+
295+
def monotonic_now
296+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
268297
end
269298

270299
def multipart_request?(data)
@@ -280,10 +309,10 @@ def make_multipart_request(method, path, query_params, data)
280309
payload = {}
281310

282311
# Handle file field
283-
raise APIError, 'file name must be provided' if data.file.nil? || data.file.empty?
312+
raise ArgumentError, 'file name must be provided' if data.file.nil? || data.file.empty?
284313

285314
file_path = data.file
286-
raise APIError, "file not found: #{file_path}" unless File.exist?(file_path)
315+
raise ArgumentError, "file not found: #{file_path}" unless File.exist?(file_path)
287316

288317
# Determine content type
289318
content_type = detect_content_type(file_path)
@@ -319,7 +348,7 @@ def make_multipart_request(method, path, query_params, data)
319348

320349
handle_response(response)
321350
rescue Faraday::Error => e
322-
raise APIError, "Request failed: #{e.message}"
351+
raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e))
323352
end
324353

325354
def detect_content_type(file_path)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
require 'time'
5+
6+
module GetStreamRuby
7+
8+
# Translates HTTP responses and Faraday errors into SDK exceptions.
9+
module ErrorMapping
10+
11+
module_function
12+
13+
# Raises the appropriate `ApiError` / `RateLimitError` for a non-2xx
14+
# `Faraday::Response`.
15+
def raise_api_error(response)
16+
parsed_body = parse_error_body(response.body)
17+
raw_body = stringify_body(response.body)
18+
19+
if parsed_body.is_a?(Hash)
20+
api_error = GetStream::Generated::Models::APIError.new(parsed_body)
21+
attrs = api_error_attrs(api_error, response.status, raw_body)
22+
23+
if response.status == 429
24+
raise RateLimitError.new(
25+
retry_after: parse_retry_after(response_header(response, 'Retry-After')),
26+
**attrs,
27+
)
28+
end
29+
30+
raise ApiError.new(**attrs)
31+
end
32+
33+
raise ApiError.new(
34+
message: 'failed to parse error response',
35+
status_code: response.status,
36+
code: 0,
37+
exception_fields: {},
38+
unrecoverable: false,
39+
raw_response_body: raw_body,
40+
more_info: nil,
41+
details: nil,
42+
)
43+
end
44+
45+
def api_error_attrs(model, status, raw_body)
46+
{
47+
message: model.message || "Request failed with status #{status}",
48+
status_code: status,
49+
code: model.code || 0,
50+
exception_fields: model.exception_fields || {},
51+
unrecoverable: model.unrecoverable.nil? ? false : model.unrecoverable,
52+
raw_response_body: raw_body,
53+
more_info: model.more_info,
54+
details: model.details,
55+
}
56+
end
57+
58+
def parse_error_body(body)
59+
return body if body.is_a?(Hash)
60+
return nil unless body.is_a?(String) && !body.empty?
61+
62+
JSON.parse(body)
63+
rescue JSON::ParserError
64+
nil
65+
end
66+
67+
def stringify_body(body)
68+
return '' if body.nil?
69+
return body if body.is_a?(String)
70+
71+
body.to_json
72+
end
73+
74+
def response_header(response, name)
75+
headers = response.headers
76+
return nil if headers.nil?
77+
78+
# Faraday normalizes header names to lowercase, but tolerate either form.
79+
headers[name] || headers[name.downcase] || headers[name.to_s]
80+
end
81+
82+
# Parse Retry-After header. Returns Float seconds. Returns nil when absent or
83+
# unparseable. Past HTTP-dates clamp to 0.
84+
def parse_retry_after(header)
85+
return nil if header.nil?
86+
87+
value = header.to_s.strip
88+
return nil if value.empty?
89+
return value.to_f if value.match?(/\A\d+\z/)
90+
91+
begin
92+
target = Time.httpdate(value)
93+
delta = target - Time.now
94+
delta.negative? ? 0.0 : delta.to_f
95+
rescue ArgumentError
96+
nil
97+
end
98+
end
99+
100+
def classify_faraday_error(error)
101+
case error
102+
when Faraday::TimeoutError
103+
'timeout'
104+
when Faraday::SSLError
105+
'tls_handshake_failed'
106+
when Faraday::ConnectionFailed
107+
classify_connection_failure(error)
108+
else
109+
'unknown'
110+
end
111+
end
112+
113+
def classify_connection_failure(error)
114+
wrapped = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
115+
case wrapped
116+
when SocketError
117+
'dns_failure'
118+
else
119+
'connection_reset'
120+
end
121+
end
122+
123+
def build_task_error(task_id, error_payload)
124+
hash = if error_payload.respond_to?(:to_h)
125+
error_payload.to_h
126+
else
127+
error_payload || {}
128+
end
129+
TaskError.new(
130+
task_id: task_id,
131+
error_type: lookup(hash, :type) || '',
132+
description: lookup(hash, :description) || '',
133+
stack_trace: lookup(hash, :stacktrace),
134+
version: lookup(hash, :version),
135+
)
136+
end
137+
138+
def lookup(hash, key)
139+
hash[key] || hash[key.to_s]
140+
end
141+
142+
end
143+
144+
end

0 commit comments

Comments
 (0)