getstream-ruby 6.1.1 → 7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f8a1dd8b4379c6af81682b352877f55c651f081265e87cc5ec08a477b19c91d
4
- data.tar.gz: 7b46eb14dac0d23a0f76223244125ec4ff54ee2130f5b554fae68e363ac7ac00
3
+ metadata.gz: 388126d2a284a9b97ac8eb6cd1793088ccfc51992504de016192d7974d582e3e
4
+ data.tar.gz: 3fb211b156d236f9b27734651215351d9a3a370d6d6a1da52bfe5fa779cdf7b9
5
5
  SHA512:
6
- metadata.gz: 4b88721605b6c6cc61769dee8c0940ad9b351676a7ed77c0ac9799f898dc3c297534dff333710617ebd4de527387b3273b66a31c7b49808d18f08f908b91d172
7
- data.tar.gz: 58ccb9e4f16e22cce7dfff31c915f9c2c4c9be2a3f5a3e14ad6c31182f1ebb4da3cdecf63321ca19920abe15be0ce1a669e248ceaa80aab99c4a3413150669ea
6
+ metadata.gz: ebae01df899c155f88efa4296b92766df5cc08f9dbd6b81188de15ddccc791eb7a5d4c1f8767c8e20c95601c109fb07de9e586bcb028786f8f6ce4904ad76352
7
+ data.tar.gz: 487609c26921506f9b5ea2cccedec98911adcd831189b785ede0963d75df993fa4ff768745574c37ac4d2f10764677f104c8ff403b1bef87f2ea867874abdd63
data/README.md CHANGED
@@ -350,18 +350,20 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/getstr
350
350
 
351
351
  ## Release Process
352
352
 
353
- Releases use two paths:
353
+ Releases use two paths, both handled by `.github/workflows/release.yml`:
354
354
 
355
- - Default: automatic release when a PR is merged to `main`/`master`.
356
- - Fallback: manual release using `.github/workflows/manual-release.yml` (admin use only).
355
+ - **Default**: automatic release when a PR is merged to `main`/`master`. The PR title (and body) drives the semver bump.
356
+ - **Fallback**: manual release via the `Release` workflow's `workflow_dispatch` (admin use). Select a `version_bump` (`patch`/`minor`/`major`). `use_current_version=true` skips the bump and publishes whatever is already in `lib/getstream_ruby/version.rb`.
357
357
 
358
- Automatic semver bump rules are based on merged PR title/body:
358
+ Automatic semver bump rules:
359
359
 
360
360
  - `feat:` -> minor
361
361
  - `fix:` (or `bug:`) -> patch
362
- - `feat!:` or `BREAKING CHANGE` in PR body -> major
362
+ - `feat!:`, `<type>(scope)!:`, or `BREAKING CHANGE` in the PR body/title -> major
363
363
 
364
- PRs with other prefixes do not trigger a release.
364
+ PRs with any other prefix do not trigger a release.
365
+
366
+ The release pipeline runs lint (`make format-check && make lint && make security`), the unit suite (`make test`), and all three integration suites (chat, feed, video) on the merged commit before publishing to RubyGems. Each step is idempotent; a failed run can be re-dispatched from the Actions UI.
365
367
 
366
368
  ## License
367
369
 
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'faraday/gzip'
4
5
  require 'faraday/retry'
5
6
  require 'faraday/multipart'
7
+ require 'faraday/net_http_persistent'
8
+ require 'logger'
6
9
  require 'json'
7
10
  require 'jwt'
8
11
  require_relative 'generated/base_model'
@@ -13,7 +16,10 @@ require_relative 'generated/chat_client'
13
16
  require_relative 'generated/video_client'
14
17
  require_relative 'extensions/moderation_extensions'
15
18
  require_relative 'generated/feed'
19
+ require_relative 'generated/webhook'
20
+ require_relative 'generated/models/api_error'
16
21
  require_relative 'stream_response'
22
+ require_relative 'error_mapping'
17
23
 
18
24
  module GetStreamRuby
19
25
 
@@ -35,6 +41,7 @@ module GetStreamRuby
35
41
 
36
42
  @configuration.validate!
37
43
  @connection = build_connection
44
+ @configuration.log_pool_config_to(@configuration.logger)
38
45
  end
39
46
 
40
47
  def feed_resource
@@ -76,6 +83,52 @@ module GetStreamRuby
76
83
  GetStream::Generated::Feed.new(self, feed_group_id, feed_id)
77
84
  end
78
85
 
86
+ # Verify a webhook signature using this client's API secret (CHA-2961).
87
+ #
88
+ # Convenience wrapper around StreamChat::Webhook.verify_signature that
89
+ # supplies the secret automatically. The module-level method is still
90
+ # available for callers that need to verify with an arbitrary secret.
91
+ #
92
+ # @param body [String] The raw request body (already-decompressed)
93
+ # @param signature [String] The signature from the X-Signature header
94
+ # @return [Boolean] true if the signature is valid, false otherwise
95
+ def verify_signature(body, signature)
96
+ StreamChat::Webhook.verify_signature(body, signature, @configuration.api_secret)
97
+ end
98
+
99
+ # Verify and parse a webhook payload in one call, using this client's API
100
+ # secret (CHA-2961).
101
+ #
102
+ # Handles gzip-compressed bodies transparently. Raises
103
+ # StreamChat::Webhook::InvalidWebhookError on signature mismatch or parse
104
+ # failures; distinguish failure modes via the message substring.
105
+ #
106
+ # @param body [String] raw request body (possibly gzip-compressed)
107
+ # @param signature [String] X-Signature header value
108
+ # @return [Object] the typed event class instance or
109
+ # StreamChat::Webhook::UnknownEvent
110
+ # @raise [StreamChat::Webhook::InvalidWebhookError]
111
+ def verify_and_parse_webhook(body, signature)
112
+ StreamChat::Webhook.verify_and_parse_webhook(body, signature, @configuration.api_secret)
113
+ end
114
+
115
+ # Decode + parse a Stream-delivered SQS message body.
116
+ #
117
+ # Convenience wrapper around StreamChat::Webhook.parse_sqs. No signature is
118
+ # required; SQS deliveries are authenticated via AWS IAM.
119
+ def parse_sqs(message_body)
120
+ StreamChat::Webhook.parse_sqs(message_body)
121
+ end
122
+
123
+ # Decode + parse a Stream-delivered SNS notification body.
124
+ #
125
+ # Accepts either the raw SNS HTTP envelope JSON or the pre-extracted Message
126
+ # string. Convenience wrapper around StreamChat::Webhook.parse_sns. No signature
127
+ # is required; SNS deliveries are authenticated via AWS IAM.
128
+ def parse_sns(notification_body)
129
+ StreamChat::Webhook.parse_sns(notification_body)
130
+ end
131
+
79
132
  # @param path [String] The API path
80
133
  # @param body [Hash] The request body
81
134
  # @return [GetStreamRuby::StreamResponse] The API response
@@ -83,7 +136,50 @@ module GetStreamRuby
83
136
  request(:post, path, body)
84
137
  end
85
138
 
86
- def make_request(method, path, query_params: nil, body: nil)
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
+
182
+ def make_request(method, path, query_params: nil, body: nil, request_timeout: nil)
87
183
  # Handle query parameters
88
184
  if query_params && !query_params.empty?
89
185
  query_string = query_params.map { |k, v| "#{k}=#{v}" }.join('&')
@@ -91,12 +187,12 @@ module GetStreamRuby
91
187
  end
92
188
 
93
189
  # Make the request
94
- request(method, path, body)
190
+ request(method, path, body, request_timeout: request_timeout)
95
191
  end
96
192
 
97
193
  private
98
194
 
99
- def request(method, path, data = {})
195
+ def request(method, path, data = {}, request_timeout: nil)
100
196
  # Add API key to query parameters
101
197
  query_params = { api_key: @configuration.api_key }
102
198
 
@@ -111,15 +207,20 @@ module GetStreamRuby
111
207
  req.headers['stream-auth-type'] = 'jwt'
112
208
  req.headers['X-Stream-Client'] = user_agent
113
209
  req.body = data.to_json
210
+ req.options.timeout = request_timeout if request_timeout
114
211
 
115
212
  end
116
213
 
117
214
  handle_response(response)
118
215
  rescue Faraday::Error => e
119
- raise APIError, "Request failed: #{e.message}"
216
+ raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e))
120
217
  end
121
218
 
122
219
  def build_connection
220
+ # Escape hatch #1: user supplied a fully-built Faraday::Connection.
221
+ # Use it as-is; none of the 5 knobs apply.
222
+ return @configuration.http_client if @configuration.http_client
223
+
123
224
  Faraday.new(url: @configuration.base_url) do |conn|
124
225
 
125
226
  conn.request :multipart
@@ -130,22 +231,44 @@ module GetStreamRuby
130
231
  backoff_factor: 2,
131
232
  }
132
233
  conn.response :json, content_type: /\bjson$/
133
- conn.headers['Connection'] = 'keep-alive' if @configuration.connection_keep_alive
234
+ # :gzip must come after :json (Faraday runs response middleware in reverse).
235
+ conn.request :gzip
134
236
  configure_adapter(conn)
135
- conn.options.timeout = @configuration.timeout
237
+
238
+ conn.options.timeout = @configuration.request_timeout
239
+ conn.options.open_timeout = @configuration.connect_timeout
136
240
 
137
241
  end
138
242
  end
139
243
 
140
244
  def configure_adapter(connection)
141
- adapter = @configuration.faraday_adapter || Faraday.default_adapter
142
- adapter_options = @configuration.faraday_adapter_options || {}
143
- connection.adapter(adapter, **adapter_options)
245
+ # Escape hatch #2: custom adapter symbol. Use it with the user's
246
+ # adapter_options; do NOT apply pool_size/idle_timeout (those are
247
+ # net_http_persistent-specific).
248
+ if @configuration.faraday_adapter
249
+ adapter = @configuration.faraday_adapter
250
+ adapter_options = @configuration.faraday_adapter_options || {}
251
+ # Header-based keep-alive only on the custom-adapter path.
252
+ # net_http_persistent (default) keeps connections alive natively without any HTTP header.
253
+ connection.headers['Connection'] = 'keep-alive' if @configuration.connection_keep_alive
254
+ connection.adapter(adapter, **adapter_options)
255
+ return
256
+ end
257
+
258
+ # Default: net_http_persistent with the 5-knob config.
259
+ # Never set Connection: close; net_http_persistent keeps connections alive natively.
260
+ idle = @configuration.idle_timeout
261
+ connection.adapter :net_http_persistent, pool_size: @configuration.max_conns_per_host do |http|
262
+
263
+ http.idle_timeout = idle
264
+
265
+ end
144
266
  rescue Faraday::Error, ArgumentError => e
145
- @configuration.logger&.warn(
146
- "Falling back to #{Faraday.default_adapter}: could not use adapter #{adapter} (#{e.message})",
147
- )
267
+ # A fallback silently disables pooling, so always WARN (never swallow).
268
+ @configuration.warn_pool_fallback(Faraday.default_adapter, e)
148
269
  connection.adapter Faraday.default_adapter
270
+ # Record the adapter actually built so the INFO log reports it accurately.
271
+ @configuration.effective_adapter = Faraday.default_adapter.to_s
149
272
  end
150
273
 
151
274
  def generate_auth_header
@@ -164,29 +287,13 @@ module GetStreamRuby
164
287
  end
165
288
 
166
289
  def handle_response(response)
167
- case response.status
168
- when 200..299
169
- StreamResponse.new(response.body)
170
- else
171
- # Parse JSON response body if it's a string
172
- parsed_body = if response.body.is_a?(String)
173
- begin
174
- JSON.parse(response.body)
175
- rescue JSON::ParserError
176
- response.body
177
- end
178
- else
179
- response.body
180
- end
181
-
182
- error_message = if parsed_body.is_a?(Hash)
183
- parsed_body['message'] || parsed_body['detail'] ||
184
- "Request failed with status #{response.status}"
185
- else
186
- "Request failed with status #{response.status}"
187
- end
188
- raise APIError, error_message
189
- 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)
190
297
  end
191
298
 
192
299
  def multipart_request?(data)
@@ -202,10 +309,10 @@ module GetStreamRuby
202
309
  payload = {}
203
310
 
204
311
  # Handle file field
205
- 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?
206
313
 
207
314
  file_path = data.file
208
- 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)
209
316
 
210
317
  # Determine content type
211
318
  content_type = detect_content_type(file_path)
@@ -241,7 +348,7 @@ module GetStreamRuby
241
348
 
242
349
  handle_response(response)
243
350
  rescue Faraday::Error => e
244
- raise APIError, "Request failed: #{e.message}"
351
+ raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e))
245
352
  end
246
353
 
247
354
  def detect_content_type(file_path)
@@ -1,45 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
4
+
3
5
  module GetStreamRuby
4
6
 
5
7
  class Configuration
6
8
 
7
9
  attr_accessor :api_key, :api_secret, :base_url, :timeout, :logger, :faraday_adapter, :faraday_adapter_options,
8
- :connection_keep_alive
10
+ :connection_keep_alive, :max_conns_per_host, :idle_timeout, :connect_timeout,
11
+ :request_timeout, :http_client, :effective_adapter
9
12
 
10
13
  def initialize(api_key: nil, api_secret: nil, use_env: true, **options)
11
- base_url = options[:base_url]
12
- timeout = options[:timeout]
13
14
  http_options = options[:http_options] || {}
14
- faraday_adapter = options[:faraday_adapter] || http_options[:faraday_adapter]
15
- faraday_adapter_options = options[:faraday_adapter_options] || http_options[:faraday_adapter_options]
16
- connection_keep_alive = if options.key?(:connection_keep_alive)
17
- options[:connection_keep_alive]
18
- else
19
- http_options[:connection_keep_alive]
20
- end
21
15
 
22
- if use_env
23
- @api_key = api_key || ENV.fetch('STREAM_API_KEY', nil)
24
- @api_secret = api_secret || ENV.fetch('STREAM_API_SECRET', nil)
25
- @base_url = base_url || ENV['STREAM_BASE_URL'] || 'https://chat.stream-io-api.com'
26
- @timeout = timeout || (ENV['STREAM_TIMEOUT'] || 30).to_i
27
- else
28
- # Manual configuration only - no environment variables
29
- @api_key = api_key
30
- @api_secret = api_secret
31
- @base_url = base_url || 'https://chat.stream-io-api.com'
32
- @timeout = timeout || 30
33
- end
16
+ assign_credentials_and_url(api_key, api_secret, options[:base_url], use_env: use_env)
17
+ assign_timeouts_and_pool(options, use_env: use_env)
18
+ assign_adapter(options, http_options)
19
+ assign_keep_alive(options, http_options)
34
20
 
35
- @faraday_adapter = (faraday_adapter || ENV.fetch('STREAM_FARADAY_ADAPTER', nil))&.to_sym
36
- @faraday_adapter_options = faraday_adapter_options || default_adapter_options
37
- @connection_keep_alive = if connection_keep_alive.nil?
38
- ENV.fetch('STREAM_CONNECTION_KEEP_ALIVE', 'true') == 'true'
39
- else
40
- connection_keep_alive
41
- end
42
- @logger = nil
21
+ # Keep @timeout in sync with @request_timeout for backwards compatibility.
22
+ @timeout = @request_timeout
23
+ @http_client = options[:http_client]
24
+ @logger = options[:logger]
43
25
  end
44
26
 
45
27
  def valid?
@@ -57,9 +39,54 @@ module GetStreamRuby
57
39
  api_secret: @api_secret,
58
40
  base_url: @base_url,
59
41
  timeout: @timeout,
42
+ request_timeout: @request_timeout,
43
+ max_conns_per_host: @max_conns_per_host,
44
+ idle_timeout: @idle_timeout,
45
+ connect_timeout: @connect_timeout,
46
+ http_client: @http_client,
60
47
  faraday_adapter: @faraday_adapter,
61
48
  faraday_adapter_options: @faraday_adapter_options.dup,
62
49
  connection_keep_alive: @connection_keep_alive,
50
+ logger: @logger,
51
+ )
52
+ end
53
+
54
+ # Emit a single INFO line listing the 5 effective pool knobs plus the active escape hatch (CHA-2956).
55
+ # If no logger is supplied, a default $stdout INFO logger is used.
56
+ # The faraday_adapter label reflects the adapter actually built
57
+ # (effective_adapter, set by Client#configure_adapter) so a silent fallback
58
+ # to the default adapter is never misreported as the requested adapter.
59
+ def log_pool_config_to(logger)
60
+ logger ||= Logger.new($stdout).tap { |l| l.level = Logger::INFO }
61
+ flag = @http_client ? 'user_http_client=true' : 'user_http_client=false'
62
+ adapter_label = if @http_client
63
+ 'user-supplied'
64
+ elsif @effective_adapter
65
+ @effective_adapter
66
+ elsif @faraday_adapter
67
+ @faraday_adapter.to_s
68
+ else
69
+ 'default'
70
+ end
71
+ fmt = 'connection pool: max_conns_per_host=%<m>d idle_timeout=%<i>d ' \
72
+ 'connect_timeout=%<c>d request_timeout=%<r>d %<flag>s faraday_adapter=%<a>s'
73
+ logger.info(
74
+ format(
75
+ fmt,
76
+ m: @max_conns_per_host, i: @idle_timeout, c: @connect_timeout,
77
+ r: @request_timeout, flag: flag, a: adapter_label
78
+ ),
79
+ )
80
+ end
81
+
82
+ # Emit a WARNING that the requested adapter could not be built and pooling
83
+ # is disabled (CHA-2956). A fallback must never be silent, so when no logger
84
+ # is configured this uses a default $stdout logger, exactly like
85
+ # log_pool_config_to.
86
+ def warn_pool_fallback(fallback_adapter, error)
87
+ warn_logger = @logger || Logger.new($stdout).tap { |l| l.level = Logger::WARN }
88
+ warn_logger.warn(
89
+ "Falling back to #{fallback_adapter}: could not configure net_http_persistent (#{error.message})",
63
90
  )
64
91
  end
65
92
 
@@ -90,6 +117,59 @@ module GetStreamRuby
90
117
  {}
91
118
  end
92
119
 
120
+ def assign_credentials_and_url(api_key, api_secret, base_url, use_env:)
121
+ if use_env
122
+ @api_key = api_key || ENV.fetch('STREAM_API_KEY', nil)
123
+ @api_secret = api_secret || ENV.fetch('STREAM_API_SECRET', nil)
124
+ @base_url = base_url || ENV.fetch('STREAM_BASE_URL', nil) || 'https://chat.stream-io-api.com'
125
+ else
126
+ @api_key = api_key
127
+ @api_secret = api_secret
128
+ @base_url = base_url || 'https://chat.stream-io-api.com'
129
+ end
130
+ end
131
+
132
+ def assign_timeouts_and_pool(options, use_env:)
133
+ timeout = options[:timeout]
134
+ request_timeout = options[:request_timeout]
135
+ max_conns_per_host = options[:max_conns_per_host]
136
+ idle_timeout = options[:idle_timeout]
137
+ connect_timeout = options[:connect_timeout]
138
+
139
+ if use_env
140
+ env_request_timeout = ENV.fetch('STREAM_REQUEST_TIMEOUT', nil) || ENV.fetch('STREAM_TIMEOUT', nil)
141
+ @request_timeout = (request_timeout || timeout || env_request_timeout || 30).to_i
142
+ @max_conns_per_host = (max_conns_per_host || ENV.fetch('STREAM_MAX_CONNS_PER_HOST', nil) || 5).to_i
143
+ @idle_timeout = (idle_timeout || ENV.fetch('STREAM_IDLE_TIMEOUT', nil) || 55).to_i
144
+ @connect_timeout = (connect_timeout || ENV.fetch('STREAM_CONNECT_TIMEOUT', nil) || 10).to_i
145
+ else
146
+ @request_timeout = (request_timeout || timeout || 30).to_i
147
+ @max_conns_per_host = (max_conns_per_host || 5).to_i
148
+ @idle_timeout = (idle_timeout || 55).to_i
149
+ @connect_timeout = (connect_timeout || 10).to_i
150
+ end
151
+ end
152
+
153
+ def assign_adapter(options, http_options)
154
+ faraday_adapter = options[:faraday_adapter] || http_options[:faraday_adapter]
155
+ faraday_adapter_options = options[:faraday_adapter_options] || http_options[:faraday_adapter_options]
156
+ @faraday_adapter = (faraday_adapter || ENV.fetch('STREAM_FARADAY_ADAPTER', nil))&.to_sym
157
+ @faraday_adapter_options = faraday_adapter_options || default_adapter_options
158
+ end
159
+
160
+ def assign_keep_alive(options, http_options)
161
+ connection_keep_alive = if options.key?(:connection_keep_alive)
162
+ options[:connection_keep_alive]
163
+ else
164
+ http_options[:connection_keep_alive]
165
+ end
166
+ @connection_keep_alive = if connection_keep_alive.nil?
167
+ ENV.fetch('STREAM_CONNECTION_KEEP_ALIVE', 'true') == 'true'
168
+ else
169
+ connection_keep_alive
170
+ end
171
+ end
172
+
93
173
  end
94
174
 
95
175
  end
@@ -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