apollo-deploy-signal-sdk 1.0.5

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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/LICENSE +21 -0
  4. data/README.md +79 -0
  5. data/docs/README.md +30 -0
  6. data/docs/domains/api-keys.md +92 -0
  7. data/docs/domains/contact-properties.md +122 -0
  8. data/docs/domains/contacts.md +394 -0
  9. data/docs/domains/emails.md +150 -0
  10. data/docs/domains/metrics.md +131 -0
  11. data/docs/domains/projects.md +127 -0
  12. data/docs/domains/segments.md +116 -0
  13. data/docs/domains/sending-domains.md +160 -0
  14. data/docs/domains/suppressions.md +120 -0
  15. data/docs/domains/topics.md +141 -0
  16. data/docs/domains/webhooks.md +200 -0
  17. data/docs/types.md +1225 -0
  18. data/lib/apollo-deploy-signal-sdk.rb +3 -0
  19. data/lib/apollo_deploy_signal_sdk/client.rb +111 -0
  20. data/lib/apollo_deploy_signal_sdk/errors.rb +150 -0
  21. data/lib/apollo_deploy_signal_sdk/resources/api-keys.rb +86 -0
  22. data/lib/apollo_deploy_signal_sdk/resources/contact-properties.rb +108 -0
  23. data/lib/apollo_deploy_signal_sdk/resources/contacts.rb +356 -0
  24. data/lib/apollo_deploy_signal_sdk/resources/emails.rb +128 -0
  25. data/lib/apollo_deploy_signal_sdk/resources/metrics.rb +122 -0
  26. data/lib/apollo_deploy_signal_sdk/resources/projects.rb +114 -0
  27. data/lib/apollo_deploy_signal_sdk/resources/segments.rb +105 -0
  28. data/lib/apollo_deploy_signal_sdk/resources/sending-domains.rb +144 -0
  29. data/lib/apollo_deploy_signal_sdk/resources/suppressions.rb +104 -0
  30. data/lib/apollo_deploy_signal_sdk/resources/topics.rb +126 -0
  31. data/lib/apollo_deploy_signal_sdk/resources/webhooks.rb +184 -0
  32. data/lib/apollo_deploy_signal_sdk/transport.rb +618 -0
  33. data/lib/apollo_deploy_signal_sdk/types.rb +4917 -0
  34. data/lib/apollo_deploy_signal_sdk/version.rb +5 -0
  35. data/lib/apollo_deploy_signal_sdk.rb +47 -0
  36. metadata +135 -0
@@ -0,0 +1,618 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+ require "uri"
8
+ require_relative "errors"
9
+
10
+ module ApolloDeploySignalSdk
11
+ class SSEEvent
12
+ attr_reader :type, :data, :id, :retry_milliseconds
13
+
14
+ def initialize(type:, data:, id: nil, retry_milliseconds: nil)
15
+ @type = type
16
+ @data = data
17
+ @id = id
18
+ @retry_milliseconds = retry_milliseconds
19
+ end
20
+ end
21
+
22
+ # HTTP transport layer built on Faraday.
23
+ class Transport
24
+ RETRYABLE_METHODS = %i[get head options put delete].freeze
25
+ RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
26
+ MAX_RETRIES = 8
27
+ MAX_RETRY_DELAY = 30.0
28
+ MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024
29
+ MAX_SSE_LINE_BYTES = 1024 * 1024
30
+
31
+ COOKIE_SAFE_CHARACTERS = "!#$%&'()*+-./:<=>?@[]^_`{|}~".freeze
32
+
33
+ class SSEParser
34
+ def initialize(output)
35
+ @output = output
36
+ @buffer = +""
37
+ @event_type = "message"
38
+ @data_lines = []
39
+ @event_id = nil
40
+ @retry_milliseconds = nil
41
+ @total_bytes = 0
42
+ end
43
+
44
+ def write(chunk)
45
+ @total_bytes += chunk.to_s.bytesize
46
+ raise_response_limit if @total_bytes > MAX_RESPONSE_BODY_BYTES
47
+
48
+ @buffer << chunk.to_s
49
+ consume_lines
50
+ end
51
+
52
+ def finish
53
+ unless @buffer.empty?
54
+ line = @buffer.end_with?("\r") ? @buffer.byteslice(0, @buffer.bytesize - 1) : @buffer
55
+ process_line(line)
56
+ end
57
+ finish_event
58
+ end
59
+
60
+ private
61
+
62
+ def consume_lines
63
+ loop do
64
+ index = @buffer.index(/[\r\n]/)
65
+ break if index.nil?
66
+
67
+ byte = @buffer.getbyte(index)
68
+ break if byte == 13 && index == @buffer.bytesize - 1
69
+
70
+ delimiter_length = byte == 13 && @buffer.getbyte(index + 1) == 10 ? 2 : 1
71
+ line = @buffer.byteslice(0, index)
72
+ @buffer = @buffer.byteslice(index + delimiter_length, @buffer.bytesize) || +""
73
+ process_line(line)
74
+ end
75
+
76
+ raise_line_limit if @buffer.bytesize > MAX_SSE_LINE_BYTES
77
+ end
78
+
79
+ def process_line(line)
80
+ raise_line_limit if line.bytesize > MAX_SSE_LINE_BYTES
81
+ if line.empty?
82
+ finish_event
83
+ return
84
+ end
85
+ return if line.start_with?(":")
86
+
87
+ separator = line.index(":")
88
+ if separator
89
+ field = line.byteslice(0, separator)
90
+ value = line.byteslice(separator + 1, line.bytesize) || ""
91
+ value = value.byteslice(1, value.bytesize) if value.start_with?(" ")
92
+ else
93
+ field = line
94
+ value = ""
95
+ end
96
+
97
+ case field
98
+ when "event"
99
+ @event_type = value
100
+ when "data"
101
+ @data_lines << value
102
+ when "id"
103
+ @event_id = value unless value.include?("\0")
104
+ when "retry"
105
+ @retry_milliseconds = Integer(value, 10) if value.match?(/\A\d+\z/)
106
+ end
107
+ rescue ArgumentError
108
+ # Invalid retry fields are ignored by the SSE protocol.
109
+ end
110
+
111
+ def finish_event
112
+ return reset_event if @data_lines.empty?
113
+
114
+ raw_data = @data_lines.join("\n")
115
+ data = begin
116
+ JSON.parse(raw_data)
117
+ rescue JSON::ParserError
118
+ raw_data
119
+ end
120
+ @output << SSEEvent.new(
121
+ type: @event_type,
122
+ data: data,
123
+ id: @event_id,
124
+ retry_milliseconds: @retry_milliseconds,
125
+ )
126
+ reset_event
127
+ end
128
+
129
+ def reset_event
130
+ @event_type = "message"
131
+ @data_lines = []
132
+ @event_id = nil
133
+ @retry_milliseconds = nil
134
+ end
135
+
136
+ def raise_response_limit
137
+ raise SDKError.new(
138
+ "Response body exceeded the configured safety limit",
139
+ 0,
140
+ "response_body_too_large",
141
+ )
142
+ end
143
+
144
+ def raise_line_limit
145
+ raise SDKError.new(
146
+ "SSE line exceeded the configured safety limit",
147
+ 0,
148
+ "sse_line_too_large",
149
+ )
150
+ end
151
+ end
152
+
153
+ attr_reader :config
154
+ attr_reader :connection
155
+
156
+ def initialize(config)
157
+ @config = config
158
+ base_uri = URI.parse(config.base_url)
159
+ unless base_uri.is_a?(URI::HTTP) && base_uri.host
160
+ raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
161
+ end
162
+ @base_query_pairs = URI.decode_www_form(base_uri.query.to_s)
163
+ base_uri.query = nil
164
+ base_uri.fragment = nil
165
+ base_path = base_uri.path.to_s.sub(%r{/+\z}, "")
166
+ base_uri.path = base_path.empty? ? "/" : "#{base_path}/"
167
+ @connection = Faraday.new(url: base_uri.to_s) do |faraday|
168
+ faraday.request :json
169
+ faraday.response :raise_error
170
+ faraday.options.timeout = config.timeout
171
+ faraday.options.open_timeout = [config.timeout / 3.0, 2.0].max
172
+ faraday.headers["Accept"] = "application/json"
173
+ faraday.headers["User-Agent"] = "apollo-deploy-signal-sdk-ruby-sdk/1.0.5"
174
+ config.default_headers.each { |key, value| faraday.headers[key] = value }
175
+ faraday.adapter Faraday.default_adapter
176
+ end
177
+ end
178
+
179
+ def request(method:, path:, path_params: {}, query: nil, body: nil, headers: {}, content_type: nil, timeout_ms: nil)
180
+ url = append_query(build_url(path, path_params), build_query(query))
181
+ method = method.to_sym
182
+ max_retries = [[(@config.retries[:attempts] || 3).to_i, 0].max, MAX_RETRIES].min
183
+ attempt = 0
184
+
185
+ begin
186
+ request_headers = build_headers(headers, content_type)
187
+ prepared_body, request_headers = prepare_request_body(
188
+ body,
189
+ content_type,
190
+ request_headers,
191
+ )
192
+ response = @connection.run_request(
193
+ method,
194
+ url,
195
+ prepared_body,
196
+ request_headers,
197
+ ) do |request|
198
+ apply_timeout(request, timeout_ms)
199
+ end
200
+ parse_response(response)
201
+ rescue Faraday::Error => error
202
+ mapped = map_error(error, method: method, path: path)
203
+ unless retryable_request?(method, headers) && mapped.retryable? && attempt < max_retries
204
+ raise mapped
205
+ end
206
+
207
+ sleep(retry_delay(error, attempt))
208
+ attempt += 1
209
+ retry
210
+ end
211
+ end
212
+
213
+ def stream(method:, path:, path_params: {}, query: nil, body: nil, headers: {}, content_type: nil, timeout_ms: nil)
214
+ Enumerator.new do |output|
215
+ url = append_query(build_url(path, path_params), build_query(query))
216
+ parser = SSEParser.new(output)
217
+ stream_headers = build_headers(headers, content_type)
218
+ set_header(stream_headers, "Accept", "text/event-stream")
219
+ prepared_body, stream_headers = prepare_request_body(
220
+ body,
221
+ content_type,
222
+ stream_headers,
223
+ )
224
+ @connection.run_request(
225
+ method.to_sym,
226
+ url,
227
+ prepared_body,
228
+ stream_headers
229
+ ) do |request|
230
+ apply_timeout(request, timeout_ms)
231
+ request.options.on_data = proc { |chunk, _bytes| parser.write(chunk) }
232
+ end
233
+ parser.finish
234
+ rescue Faraday::Error => error
235
+ raise map_error(error, method: method, path: path)
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def apply_timeout(request, timeout_ms)
242
+ seconds = timeout_ms.nil? ? @config.timeout.to_f : timeout_ms.to_f / 1000.0
243
+ if seconds <= 0
244
+ raise SDKError.new("timeout_ms must be greater than zero", 0, "invalid_timeout")
245
+ end
246
+
247
+ request.options.timeout = seconds
248
+ request.options.open_timeout = [seconds / 3.0, 2.0].max
249
+ end
250
+
251
+ def prepare_request_body(body, content_type, headers)
252
+ return [nil, headers] if body.nil?
253
+
254
+ media_type = content_type.to_s.split(";", 2).first.to_s.strip.downcase
255
+ if media_type.empty? || media_type == "application/json" || media_type.end_with?("+json")
256
+ normalized_body = body.respond_to?(:to_h) ? body.to_h : body
257
+ return [normalized_body, headers]
258
+ end
259
+
260
+ if media_type == "multipart/form-data"
261
+ boundary = "tesseract-#{SecureRandom.hex(16)}"
262
+ multipart_headers = headers.dup
263
+ set_header(
264
+ multipart_headers,
265
+ "Content-Type",
266
+ "multipart/form-data; boundary=#{boundary}",
267
+ )
268
+ return [encode_multipart_body(body, boundary), multipart_headers]
269
+ end
270
+
271
+ [read_raw_body(body), headers]
272
+ end
273
+
274
+ def read_raw_body(body)
275
+ return body if body.is_a?(String)
276
+ return body.to_str if body.respond_to?(:to_str)
277
+
278
+ if body.respond_to?(:read)
279
+ contents = body.read
280
+ return contents.to_s if contents.nil? || contents.is_a?(String)
281
+ end
282
+
283
+ return body.to_s if body.is_a?(Numeric) || body == true || body == false
284
+
285
+ raise SDKError.new(
286
+ "Raw request bodies must be strings or readable streams",
287
+ 0,
288
+ "invalid_request_body",
289
+ )
290
+ end
291
+
292
+ def encode_multipart_body(body, boundary)
293
+ fields = if body.is_a?(Hash)
294
+ body
295
+ elsif body.respond_to?(:to_h)
296
+ body.to_h
297
+ end
298
+ unless fields.is_a?(Hash)
299
+ raise SDKError.new(
300
+ "Multipart request bodies must be hashes or model objects",
301
+ 0,
302
+ "invalid_request_body",
303
+ )
304
+ end
305
+
306
+ encoded = String.new(encoding: Encoding::BINARY)
307
+ fields.each do |name, value|
308
+ append_multipart_fields(encoded, boundary, name, value)
309
+ end
310
+ encoded << "--#{boundary}--\r\n".b
311
+ encoded
312
+ end
313
+
314
+ def append_multipart_fields(buffer, boundary, name, value)
315
+ return if value.nil?
316
+
317
+ if !value.is_a?(Hash) && !value.is_a?(Array) && value.respond_to?(:to_h) && !value.respond_to?(:read)
318
+ value = value.to_h
319
+ end
320
+
321
+ descriptor = multipart_descriptor(value)
322
+ if descriptor
323
+ append_multipart_part(
324
+ buffer,
325
+ boundary,
326
+ descriptor[:name] || name,
327
+ descriptor[:contents],
328
+ descriptor[:filename],
329
+ descriptor[:content_type],
330
+ )
331
+ elsif value.is_a?(Array)
332
+ value.each { |item| append_multipart_fields(buffer, boundary, name, item) }
333
+ elsif value.is_a?(Hash)
334
+ value.each do |nested_name, nested_value|
335
+ append_multipart_fields(
336
+ buffer,
337
+ boundary,
338
+ "#{name}[#{nested_name}]",
339
+ nested_value,
340
+ )
341
+ end
342
+ else
343
+ append_multipart_part(buffer, boundary, name, value, nil, nil)
344
+ end
345
+ end
346
+
347
+ def multipart_descriptor(value)
348
+ return nil unless value.is_a?(Hash)
349
+ return nil unless value.key?(:contents) || value.key?("contents")
350
+
351
+ keys = value.keys.map(&:to_s)
352
+ allowed = %w[contents name filename content_type contentType]
353
+ return nil unless (keys - allowed).empty?
354
+
355
+ {
356
+ name: value[:name] || value["name"],
357
+ contents: value.key?(:contents) ? value[:contents] : value["contents"],
358
+ filename: value[:filename] || value["filename"],
359
+ content_type: value[:content_type] || value["content_type"] || value["contentType"],
360
+ }
361
+ end
362
+
363
+ def append_multipart_part(buffer, boundary, name, value, filename, content_type)
364
+ contents = value
365
+ if value.respond_to?(:read)
366
+ contents = value.read.to_s
367
+ filename ||= multipart_filename(value)
368
+ content_type ||= "application/octet-stream"
369
+ end
370
+
371
+ unless contents.is_a?(String) || contents.respond_to?(:to_str)
372
+ contents = contents.to_s
373
+ end
374
+
375
+ disposition = "Content-Disposition: form-data; name=\"#{multipart_quote(name)}\""
376
+ disposition += "; filename=\"#{multipart_quote(filename)}\"" if filename
377
+
378
+ buffer << "--#{boundary}\r\n".b
379
+ buffer << disposition.b << "\r\n".b
380
+ buffer << "Content-Type: #{content_type}\r\n".b if content_type
381
+ buffer << "\r\n".b
382
+ buffer << contents.to_str.b
383
+ buffer << "\r\n".b
384
+ end
385
+
386
+ def multipart_filename(value)
387
+ if value.respond_to?(:original_filename) && value.original_filename
388
+ return value.original_filename.to_s
389
+ end
390
+
391
+ return nil unless value.respond_to?(:path)
392
+
393
+ path = value.path
394
+ path.nil? || path.to_s.empty? ? nil : File.basename(path.to_s)
395
+ end
396
+
397
+ def multipart_quote(value)
398
+ value.to_s
399
+ .gsub(/[\r\n]/, "")
400
+ .gsub(/["\\]/) { |character| "\\#{character}" }
401
+ end
402
+
403
+ def build_url(path, path_params)
404
+ expanded = if path_params.empty?
405
+ path
406
+ else
407
+ path.gsub(/(?:\$\{(\w+)\}|:(\w+))/) do |match|
408
+ key = Regexp.last_match(1) || Regexp.last_match(2)
409
+ value = if path_params.key?(key.to_sym)
410
+ path_params[key.to_sym]
411
+ elsif path_params.key?(key)
412
+ path_params[key]
413
+ end
414
+ value.nil? ? match : URI.encode_www_form_component(wire_value(value)).gsub("+", "%20")
415
+ end
416
+ end
417
+ expanded.sub(%r{\A/+}, "")
418
+ end
419
+
420
+ def build_headers(extra_headers = {}, content_type = nil)
421
+ headers = {}
422
+ headers["Content-Type"] = content_type if content_type
423
+ headers.merge!(extra_headers)
424
+ claimed_security_headers = {}
425
+ headers
426
+ end
427
+
428
+ def build_query(query)
429
+ request_pairs = []
430
+ (query || {}).each do |key, value|
431
+ next if value.nil?
432
+
433
+ values = value.is_a?(Array) ? value : [value]
434
+ values.each do |item|
435
+ request_pairs << [key.to_s, wire_value(item)] unless item.nil?
436
+ end
437
+ end
438
+
439
+ request_names = request_pairs.map(&:first).to_h { |name| [name, true] }
440
+ pairs = @base_query_pairs.reject { |key, _| request_names.key?(key) }
441
+ pairs.concat(request_pairs)
442
+ pairs
443
+ end
444
+
445
+ def append_query(path, pairs)
446
+ query = URI.encode_www_form(pairs)
447
+ query.empty? ? path : "#{path}?#{query}"
448
+ end
449
+
450
+ def wire_value(value)
451
+ return "true" if value == true
452
+ return "false" if value == false
453
+ return value.iso8601 if value.respond_to?(:iso8601)
454
+
455
+ value.to_s
456
+ end
457
+
458
+ def set_header(headers, name, value)
459
+ headers.delete_if { |key, _| key.to_s.casecmp(name).zero? }
460
+ headers[name] = value
461
+ end
462
+
463
+ def set_security_header(headers, claimed_headers, name, value)
464
+ normalized = name.to_s.downcase
465
+ if claimed_headers.key?(normalized)
466
+ raise ArgumentError, "Multiple configured security schemes target the #{name} header"
467
+ end
468
+
469
+ claimed_headers[normalized] = true
470
+ set_header(headers, name, value)
471
+ end
472
+
473
+ def credential_configured?(value)
474
+ !value.nil? && !value.to_s.empty?
475
+ end
476
+
477
+ def set_security_cookie(headers, name, value)
478
+ cookie_key = headers.keys.find { |key| key.to_s.casecmp("Cookie").zero? }
479
+ pairs = cookie_key ? headers.delete(cookie_key).to_s.split(";") : []
480
+ pairs.reject! { |pair| pair.partition("=").first.strip == name }
481
+ pairs << "#{name}=#{self.class.encode_cookie_component(value)}"
482
+ headers["Cookie"] = pairs.map(&:strip).reject(&:empty?).join("; ")
483
+ end
484
+
485
+ def self.encode_cookie_component(value)
486
+ value.to_s.bytes.map do |byte|
487
+ character = byte.chr
488
+ if (byte >= 0x30 && byte <= 0x39) ||
489
+ (byte >= 0x41 && byte <= 0x5A) ||
490
+ (byte >= 0x61 && byte <= 0x7A) ||
491
+ COOKIE_SAFE_CHARACTERS.include?(character)
492
+ character
493
+ else
494
+ format("%%%02X", byte)
495
+ end
496
+ end.join
497
+ end
498
+
499
+ def parse_response(response)
500
+ body = response.body
501
+ ensure_response_body_size!(body, response.status)
502
+ return nil if body.nil? || (body.is_a?(String) && body.strip.empty?)
503
+ return body unless body.is_a?(String)
504
+
505
+ JSON.parse(body)
506
+ rescue JSON::ParserError
507
+ body
508
+ end
509
+
510
+ def ensure_response_body_size!(body, status = 0, request_id: nil, method: nil, path: nil)
511
+ return body unless body.is_a?(String) && body.bytesize > MAX_RESPONSE_BODY_BYTES
512
+
513
+ raise SDKError.new(
514
+ "Response body exceeded the configured safety limit",
515
+ status.to_i,
516
+ "response_body_too_large",
517
+ request_id: request_id,
518
+ method: method,
519
+ path: path,
520
+ )
521
+ end
522
+
523
+ def retryable_request?(method, headers)
524
+ return true if @config.retry_unsafe_requests
525
+ return true if RETRYABLE_METHODS.include?(method)
526
+
527
+ @config.default_headers.merge(headers).any? do |key, value|
528
+ ["Idempotency-Key", "X-Idempotency-Key"].any? { |name| key.to_s.casecmp(name).zero? } &&
529
+ !value.to_s.empty?
530
+ end
531
+ end
532
+
533
+ def retry_delay(error, attempt)
534
+ retry_after = retry_after_seconds(error)
535
+ return retry_after unless retry_after.nil?
536
+
537
+ base = [(@config.retries[:backoff] || 0.4).to_f, 0.0].max
538
+ max_backoff = retry_max_backoff
539
+ delay = [base * (2**attempt), max_backoff].min
540
+ @config.retries.fetch(:jitter, true) ? rand * delay : delay
541
+ end
542
+
543
+ def retry_max_backoff
544
+ [[(@config.retries[:max_backoff] || MAX_RETRY_DELAY).to_f, 0.0].max, MAX_RETRY_DELAY].min
545
+ end
546
+
547
+ def retry_after_seconds(error)
548
+ headers = error.response&.[](:headers) || {}
549
+ value = headers["retry-after"] || headers["Retry-After"]
550
+ return nil if value.nil? || value.to_s.empty?
551
+
552
+ [[Float(value), 0.0].max, retry_max_backoff].min
553
+ rescue ArgumentError, TypeError
554
+ begin
555
+ [
556
+ [Time.httpdate(value.to_s) - Time.now, 0.0].max,
557
+ retry_max_backoff
558
+ ].min
559
+ rescue ArgumentError
560
+ nil
561
+ end
562
+ end
563
+
564
+ def map_error(error, method:, path:)
565
+ status = error.response&.[](:status).to_i
566
+ headers = error.response&.[](:headers) || {}
567
+ request_id = headers["x-request-id"] || headers["X-Request-ID"] || headers["request-id"]
568
+
569
+ case error
570
+ when Faraday::TimeoutError
571
+ SDKError.new("Request timed out", 0, "timeout", method: method, path: path)
572
+ when Faraday::ConnectionFailed
573
+ SDKError.new("Connection failed: #{error.message}", 0, "network_error", method: method, path: path)
574
+ else
575
+ body = error.response&.[](:body)
576
+ ensure_response_body_size!(
577
+ body,
578
+ status,
579
+ request_id: request_id,
580
+ method: method,
581
+ path: path,
582
+ )
583
+ envelope = parse_error_body(body)
584
+ message = envelope["message"] || envelope.dig("error", "message") || error.message
585
+ code = envelope["code"] || envelope.dig("error", "code")
586
+ code ||= envelope["error"] if envelope["error"].is_a?(String)
587
+ SDKError.new(
588
+ message,
589
+ status,
590
+ code || default_error_code(status),
591
+ request_id: envelope["request_id"] || request_id,
592
+ method: method,
593
+ path: path,
594
+ details: envelope["details"]
595
+ )
596
+ end
597
+ end
598
+
599
+ def parse_error_body(body)
600
+ return body if body.is_a?(Hash)
601
+ return {} unless body.is_a?(String) && !body.empty?
602
+
603
+ parsed = JSON.parse(body)
604
+ parsed.is_a?(Hash) ? parsed : {}
605
+ rescue JSON::ParserError
606
+ {}
607
+ end
608
+
609
+ def default_error_code(status)
610
+ return "network_error" if status.zero?
611
+ return "rate_limit_exceeded" if status == 429
612
+ return "request_timeout" if status == 408
613
+ return "internal_server_error" if status >= 500
614
+
615
+ "http_error"
616
+ end
617
+ end
618
+ end