zerobounce-sdk 2.1.8 → 3.0.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +172 -19
  3. data/README.md +477 -410
  4. data/lib/zerobounce/api_key.rb +21 -0
  5. data/lib/zerobounce/api_urls.rb +9 -0
  6. data/lib/zerobounce/base_request.rb +260 -8
  7. data/lib/zerobounce/client.rb +30 -0
  8. data/lib/zerobounce/configuration.rb +24 -3
  9. data/lib/zerobounce/error.rb +20 -0
  10. data/lib/zerobounce/get_file_helper.rb +6 -8
  11. data/lib/zerobounce/priority3.rb +351 -0
  12. data/lib/zerobounce/request.rb +84 -42
  13. data/lib/zerobounce/response_error.rb +117 -0
  14. data/lib/zerobounce/validate_sub_status.rb +1 -0
  15. data/lib/zerobounce/version.rb +1 -1
  16. data/lib/zerobounce/warmup.rb +471 -0
  17. data/lib/zerobounce.rb +112 -26
  18. metadata +46 -41
  19. data/.dockerignore +0 -17
  20. data/.env.example +0 -3
  21. data/.github/auto_assign.yml +0 -5
  22. data/.github/dependabot.yml +0 -35
  23. data/.github/workflows/auto_assign_ci.yaml +0 -21
  24. data/.github/workflows/codeql.yml +0 -105
  25. data/.github/workflows/publish.yml +0 -131
  26. data/.github/workflows/sdk_ci.yml +0 -29
  27. data/.gitignore +0 -70
  28. data/.rspec +0 -2
  29. data/.ruby-version +0 -1
  30. data/CODE_OF_CONDUCT.md +0 -69
  31. data/CONTRIBUTING.md +0 -112
  32. data/Dockerfile +0 -17
  33. data/Gemfile +0 -13
  34. data/Gemfile.lock +0 -135
  35. data/LICENSE.txt +0 -21
  36. data/Rakefile +0 -26
  37. data/SECURITY.md +0 -21
  38. data/bin/bundle +0 -105
  39. data/bin/console +0 -7
  40. data/bin/setup +0 -6
  41. data/data/zerobounce-ai-scoring.csv +0 -31
  42. data/data/zerobounce-batch-validation.csv +0 -31
  43. data/documentation.md +0 -530
  44. data/files/scoring.csv +0 -1
  45. data/files/validation.csv +0 -1
  46. data/files/zerobounce-ai-scoring.csv +0 -31
  47. data/files/zerobounce-batch-validation.csv +0 -31
  48. data/lib/zerobounce/mock_request.rb +0 -86
  49. data/zerobounce.gemspec +0 -49
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zerobounce
4
+ # Checks an API key before it is sent.
5
+ module ApiKey
6
+ module_function
7
+
8
+ # The key, unchanged, or an ArgumentError when it has surrounding
9
+ # whitespace or control characters (a key read from a file with its
10
+ # newline). Such a key would fail at the API, and the HTTP library's own
11
+ # error could quote it unredacted.
12
+ def check!(api_key)
13
+ key = api_key.to_s
14
+ if key != key.strip || key.match?(/[\u0000-\u001f\u007f]/)
15
+ raise ArgumentError, 'api_key has surrounding whitespace or control characters'
16
+ end
17
+
18
+ api_key
19
+ end
20
+ end
21
+ end
@@ -14,5 +14,14 @@ module Zerobounce
14
14
 
15
15
  # Bulk API URLs
16
16
  BULK_DEFAULT_URL = 'https://bulkapi.zerobounce.net/v2/'
17
+
18
+ # The S3 and SFTP/FTP bulk jobs live on the bulkapi host WITHOUT the /v2
19
+ # path. See https://www.zerobounce.net/docs/api-integrations.
20
+ BULK_ROOT_URL = 'https://bulkapi.zerobounce.net/'
21
+
22
+ # The list evaluator and the Inbox Placement Tester have their own base
23
+ # URLs, not paths built from the bulk one.
24
+ LIST_EVALUATOR_URL = 'https://bulkapi.zerobounce.net/v2/listevaluator/'
25
+ INBOX_PLACEMENT_URL = 'https://bulkapi.zerobounce.net/api/ipt'
17
26
  end
18
27
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'zerobounce/api_urls'
5
+ require 'zerobounce/response_error'
6
+ require 'zerobounce/api_key'
7
+ require 'zerobounce/error'
4
8
 
5
9
  # https://github.com/rest-client/rest-client
6
10
  require 'rest-client'
@@ -41,36 +45,120 @@ module Zerobounce
41
45
  canonical
42
46
  end
43
47
 
48
+ def self.__require_https__(url)
49
+ unless url.to_s.downcase.start_with?('https://')
50
+ raise ArgumentError, 'API URL must be an https:// URL'
51
+ end
52
+ url
53
+ end
54
+
55
+ # Removes the API key from error text. The key the request actually used
56
+ # (a Zerobounce::Client's or with_api_key's, which is thread-local) is
57
+ # redacted as well as the global one, in its raw and URL-encoded forms.
58
+ def self.__redact_api_key__(message)
59
+ text = message.to_s
60
+ [current_api_key, Zerobounce.config.apikey].uniq.each do |key|
61
+ next if key.nil? || key.to_s.strip.empty?
62
+
63
+ [key.to_s, URI.encode_www_form_component(key.to_s)].uniq.each do |form|
64
+ text = text.gsub(form, '***')
65
+ end
66
+ end
67
+ text
68
+ end
69
+
70
+ def self.current_api_key
71
+ key = Thread.current[:zerobounce_api_key]
72
+ key.nil? ? Zerobounce.config.apikey : key
73
+ end
74
+
75
+ # The key for this request, or an error before anything is sent. A key with
76
+ # surrounding whitespace or control characters (one read from a file with
77
+ # its newline) would go out as it is and fail, and the HTTP library could
78
+ # quote it unredacted.
79
+ def self.__checked_api_key__
80
+ api_key = current_api_key
81
+ raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
82
+
83
+ ApiKey.check!(api_key)
84
+ end
85
+
86
+ # The message for an HTTP error status.
87
+ def self.__http_error_message__(error)
88
+ code = error.http_code.to_i
89
+ # max_redirects is 0, so a 3xx is never followed; say so rather than
90
+ # reporting its (usually empty) body as an "Empty response".
91
+ if code.between?(300, 399)
92
+ return "ZeroBounce answered with a redirect (HTTP #{code}), which is not followed"
93
+ end
94
+
95
+ ResponseError.message_from(error.response)
96
+ end
97
+
98
+ def self.__execute_request__(**kwargs)
99
+ kwargs[:max_redirects] = 0
100
+ user_agent = Zerobounce.config.headers && Zerobounce.config.headers[:user_agent]
101
+ kwargs[:headers] = (kwargs[:headers] || {}).merge(user_agent: user_agent) if user_agent
102
+ RestClient::Request.execute(**kwargs)
103
+ rescue RestClient::ExceptionWithResponse => e
104
+ # Re-raised as the same RestClient class (RestClient::NotFound and so
105
+ # on), so `rescue RestClient::...` and #http_code keep working as they
106
+ # did up to 2.1.8. Only the message changes: the API's short error text,
107
+ # with the key redacted.
108
+ e.message = __redact_api_key__(__http_error_message__(e))
109
+ raise e
110
+ rescue RestClient::Exception => e
111
+ e.message = __redact_api_key__(e.message)
112
+ raise e
113
+ rescue StandardError => e
114
+ # Same class and backtrace, redacted message. cause: nil, because the
115
+ # original (unredacted) error would otherwise ride along as #cause and
116
+ # be printed by #full_message.
117
+ raise e.exception(__redact_api_key__(e.message)), cause: nil
118
+ end
119
+
44
120
  def self._get(root, path, params, content_type='application/json')
45
121
 
46
122
  # puts path
47
123
  # puts Zerobounce.config.apikey
48
124
 
49
- raise ("API key must be assigned") if not Zerobounce.config.apikey
125
+ api_key = __checked_api_key__
50
126
 
51
- params[:api_key] = Zerobounce.config.apikey
127
+ params[:api_key] = api_key
52
128
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
129
+ Zerobounce::BaseRequest.__require_https__(url)
53
130
 
54
- response = RestClient.get(url, {params: params})
131
+ response = __execute_request__(method: :get, url: url, timeout: 120, headers: { params: params })
55
132
  return response
56
133
  end
57
134
 
58
135
  def self._post(root, path, params, content_type='application/json', filepath=nil)
59
136
 
60
- raise ("API key must be assigned") if not Zerobounce.config.apikey
137
+ api_key = __checked_api_key__
61
138
 
62
- params[:api_key] = Zerobounce.config.apikey
139
+ params[:api_key] = api_key
63
140
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
141
+ Zerobounce::BaseRequest.__require_https__(url)
64
142
  response = nil
65
143
 
66
144
  if filepath or content_type == 'multipart/form-data'
145
+ params.each do |key, value|
146
+ params[key] = value ? 'true' : 'false' if value == true || value == false
147
+ end
67
148
  params[:file] = File.new(Zerobounce::BaseRequest.__safe_file_path__(filepath), 'rb')
68
149
  params[:multipart] = true
69
- response = RestClient.post(url, params)
150
+ response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
70
151
 
71
152
  elsif content_type == 'application/json'
72
- response = RestClient.post(url, params.to_json, \
73
- content_type: :json, accept: :json)
153
+ response = __execute_request__(
154
+ method: :post,
155
+ url: url,
156
+ payload: params.to_json,
157
+ timeout: 120,
158
+ headers: { content_type: :json, accept: :json }
159
+ )
160
+ elsif content_type == 'application/x-www-form-urlencoded'
161
+ response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
74
162
  else
75
163
  # this shouldn't happen
76
164
  raise Error.new('Unknown content type specified in request.'\
@@ -79,5 +167,169 @@ module Zerobounce
79
167
  return response
80
168
  end
81
169
 
170
+ # Sends a request with an explicit method and an optional form or JSON body.
171
+ #
172
+ # The endpoints ported from the Python SDK need shapes _get and _post cannot
173
+ # express: a form-encoded POST on an arbitrary base URL, and the Inbox
174
+ # Placement Tester's PUT and DELETE with the API key on the query string.
175
+ # +bearer+ sends the key in an Authorization header instead of a query
176
+ # parameter, which the Inbox Placement Tester requires and which keeps the
177
+ # key out of request logs.
178
+ def self.__execute_with_method__(method, url, params, form: nil, json: nil, bearer: false)
179
+ api_key = __checked_api_key__
180
+
181
+ params = (params || {}).dup
182
+ extra = {}
183
+ if bearer
184
+ extra[:authorization] = "Bearer #{api_key}"
185
+ else
186
+ params = params.merge(api_key: api_key)
187
+ end
188
+ Zerobounce::BaseRequest.__require_https__(url)
189
+
190
+ options = { method: method, url: url, timeout: 120 }
191
+ if form
192
+ options[:payload] = form
193
+ options[:headers] = extra.merge(params: params, content_type: 'application/x-www-form-urlencoded')
194
+ elsif json
195
+ options[:payload] = json.to_json
196
+ options[:headers] = extra.merge(params: params, content_type: :json, accept: :json)
197
+ else
198
+ options[:headers] = extra.merge(params: params)
199
+ end
200
+
201
+ __execute_request__(**options)
202
+ end
203
+
204
+ # A GET or form-encoded POST against an arbitrary base URL, for the list
205
+ # evaluator, which has its own host.
206
+ def self.root_request(method, root, path, params, form: nil)
207
+ url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
208
+ __parse_json_response__(__execute_with_method__(method, url, params, form: form))
209
+ end
210
+
211
+ # One Inbox Placement Tester call.
212
+ def self.ipt_request(method, path, params = {}, json: nil)
213
+ root = Zerobounce.configuration.inbox_placement_root_url
214
+ url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
215
+ # LIVE: the Inbox Placement Tester authenticates with a Bearer header.
216
+ # Sent as an api_key parameter the request is rejected as unauthorized.
217
+ response = __execute_with_method__(method, url, params, json: json, bearer: true)
218
+ return nil if response.body.to_s.strip.empty?
219
+
220
+ __parse_json_response__(response)
221
+ end
222
+
223
+ # Whether a Content-Type names a JSON body.
224
+ #
225
+ # The API types most responses 'application/json' but some
226
+ # 'application/json; charset=utf-8'. Compared with ==, the second kind
227
+ # skipped JSON parsing entirely, so an {"error": ...} payload came back as
228
+ # a raw String and read as a success. Only the media type decides; the
229
+ # parameters after the semicolon are ignored.
230
+ def self.__json_content_type__(content_type)
231
+ media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
232
+ media_type == 'application/json' || media_type.end_with?('+json')
233
+ end
234
+
235
+ # The root for the S3 and SFTP/FTP jobs: the bulkapi host WITHOUT /v2.
236
+ #
237
+ # +bulk_root_url+ (ZEROBOUNCE_BULK_ROOT_URL) wins when it is set, so these
238
+ # endpoints can be pointed at a mock server on their own. Left unset it is
239
+ # derived from +bulk_api_root_url+ with the /v2 stripped, so overriding
240
+ # that one alone still moves these endpoints with it.
241
+ def self.bulk_api_root_without_v2
242
+ configured = Zerobounce.configuration.bulk_root_url.to_s.strip
243
+ return __root_without_trailing_slashes__(configured) unless configured.empty?
244
+
245
+ derived = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '')
246
+ derived = ApiUrls::BULK_ROOT_URL if derived.strip.empty?
247
+ __root_without_trailing_slashes__(derived)
248
+ end
249
+
250
+ # Whether a "success" field means failure. The API sends it as a JSON
251
+ # false, "false" or "False" depending on the endpoint.
252
+ def self.__success_means_failure__(value)
253
+ return !value if value == true || value == false
254
+
255
+ value.is_a?(String) && value.strip.casecmp('false').zero?
256
+ end
257
+
258
+ # LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
259
+ # API answers an unknown bulk file id that way. Applied to the main API
260
+ # and the bulk API alike.
261
+ def self.__raise_if_success_false__(response, parsed)
262
+ return unless parsed.is_a?(Hash) && parsed.key?('success') && __success_means_failure__(parsed['success'])
263
+
264
+ raise ApiResponseError.new(
265
+ ResponseError.message_from_parts(response.code, response.headers[:content_type].to_s, response.body.to_s),
266
+ response.code
267
+ )
268
+ end
269
+
270
+ # A value that reads as an error: a non-blank String.
271
+ def self.__error_text__(value)
272
+ value.is_a?(String) && !value.strip.empty?
273
+ end
274
+
275
+ # An "error" value that means failure: anything but nil or a blank String.
276
+ def self.__error_value__(value)
277
+ !value.nil? && !(value.is_a?(String) && value.strip.empty?)
278
+ end
279
+
280
+ # The keys a message-only body is made of.
281
+ MESSAGE_KEYS = %w[error message error_message Message Error detail].freeze
282
+
283
+ # A body made only of message keys (no "success"), for the calls whose
284
+ # result is never a bare message.
285
+ def self.__raise_if_message_only__(response, parsed)
286
+ return if parsed.empty? || parsed.key?('success')
287
+ return unless parsed.keys.all? { |key| MESSAGE_KEYS.include?(key) }
288
+
289
+ text = MESSAGE_KEYS.map { |key| parsed[key] }.find { |value| __error_text__(value) }
290
+ raise ApiResponseError.new(ResponseError.strip_prefix(text), response.code) if text
291
+ end
292
+
293
+ # A dead or invalid key on an HTTP 200: no "success" field and a non-empty
294
+ # "Error" (LIVE: the filters spell it so) or "error_message".
295
+ def self.__raise_if_bare_error__(response, parsed)
296
+ return if parsed.key?('success')
297
+
298
+ %w[Error error_message].each do |key|
299
+ next unless __error_text__(parsed[key])
300
+
301
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed[key]), response.code)
302
+ end
303
+ end
304
+
305
+ def self.__parse_json_response__(response)
306
+ body = response.body.to_s
307
+ content_type = (response.headers[:content_type] || '').to_s
308
+ code = response.code
309
+ if ResponseError.html?(body, content_type) || body.strip.empty?
310
+ raise ApiResponseError.new(ResponseError.message_from_parts(code, content_type, body), code)
311
+ end
312
+ begin
313
+ parsed = JSON.parse(body)
314
+ rescue JSON::ParserError
315
+ raise ApiResponseError.new('ZeroBounce returned a response that is not valid JSON.', code)
316
+ end
317
+ return parsed unless parsed.is_a?(Hash)
318
+
319
+ __raise_if_success_false__(response, parsed)
320
+
321
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['error']), code) if __error_value__(parsed['error'])
322
+ # The Inbox Placement Tester is a Django REST framework app and reports
323
+ # its errors under "detail" rather than "error"/"message".
324
+ if __error_value__(parsed['detail'])
325
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['detail']), code)
326
+ end
327
+ __raise_if_bare_error__(response, parsed)
328
+ if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
329
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['errors'][0]['error']), code)
330
+ end
331
+ parsed
332
+ end
333
+
82
334
  end
83
335
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zerobounce/api_key'
4
+
5
+ module Zerobounce
6
+ # One API key. Package methods share Zerobounce.config; a Client does not.
7
+ class Client
8
+ def initialize(apikey)
9
+ ApiKey.check!(apikey) unless apikey.nil? || apikey.to_s.strip.empty?
10
+ @apikey = apikey
11
+ end
12
+
13
+ # Keeps the API key out of logs and consoles.
14
+ def inspect
15
+ "#<#{self.class.name} apikey=#{@apikey.nil? ? 'nil' : '[FILTERED]'}>"
16
+ end
17
+
18
+ def validate(email, ip_address = nil, **options)
19
+ Zerobounce.with_api_key(@apikey) { Zerobounce.validate(email, ip_address, **options) }
20
+ end
21
+
22
+ def credits
23
+ Zerobounce.with_api_key(@apikey) { Zerobounce.credits }
24
+ end
25
+
26
+ def validate_batch(emails, ip_addresses = [], **options)
27
+ Zerobounce.with_api_key(@apikey) { Zerobounce.validate_batch(emails, ip_addresses, **options) }
28
+ end
29
+ end
30
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rest-client'
4
- require 'dotenv'
5
4
  require_relative 'api_urls'
6
5
 
7
6
  module Zerobounce
@@ -24,19 +23,41 @@ module Zerobounce
24
23
  # @attr [String] bulk_api_root_url
25
24
  # The Zerobounce bulk API root URL.
26
25
  #
26
+ # @attr [String, nil] bulk_root_url
27
+ # The root for the S3 and SFTP/FTP bulk jobs: the bulkapi host without the
28
+ # /v2 path. Defaults to nil, which derives it from {#bulk_api_root_url}.
29
+ #
27
30
  # @attr [Array<Symbol>] valid_statues
28
31
  # The statuses that are considered valid by {Response#valid?}.
29
32
  class Configuration
30
- attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :valid_statuses, :mock
33
+ attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :bulk_root_url,
34
+ :list_evaluator_root_url, :inbox_placement_root_url, :valid_statuses, :mock
31
35
 
32
36
  def initialize(mock = false)
33
- File.file?('.env') ? Dotenv.load('.env') : Dotenv.load
34
37
  self.apikey = ENV['ZEROBOUNCE_API_KEY']
35
38
  self.api_root_url = ENV['ZEROBOUNCE_API_URL'] || ApiUrls::DEFAULT_URL
36
39
  self.bulk_api_root_url = ENV['ZEROBOUNCE_BULK_API_URL'] || ApiUrls::BULK_DEFAULT_URL
40
+ # The S3/SFTP root is the only one that can be derived from another base
41
+ # URL, so it is left unset unless it is asked for: BaseRequest then takes
42
+ # bulk_api_root_url with the /v2 stripped. Set it - in code or through
43
+ # ZEROBOUNCE_BULK_ROOT_URL - to point those jobs somewhere on their own.
44
+ self.bulk_root_url = ENV['ZEROBOUNCE_BULK_ROOT_URL']
45
+ # These two are separate base URLs rather than paths built from
46
+ # bulk_api_root_url, so overriding that alone leaves them on the live API.
47
+ self.list_evaluator_root_url = ENV['ZEROBOUNCE_LIST_EVALUATOR_URL'] || ApiUrls::LIST_EVALUATOR_URL
48
+ self.inbox_placement_root_url = ENV['ZEROBOUNCE_INBOX_PLACEMENT_URL'] || ApiUrls::INBOX_PLACEMENT_URL
37
49
  self.valid_statuses = %i[valid catch_all accept_all]
38
50
  self.headers = { user_agent: "ZerobounceRubyGem/#{Zerobounce::VERSION}" }
39
51
  self.mock = mock
40
52
  end
53
+
54
+ # Keep the API key out of logs, consoles and error pages that call #inspect.
55
+ def inspect
56
+ vars = instance_variables.map do |var|
57
+ value = var == :@apikey && apikey ? '[FILTERED]' : instance_variable_get(var).inspect
58
+ "#{var}=#{value}"
59
+ end
60
+ "#<#{self.class.name} #{vars.join(', ')}>"
61
+ end
41
62
  end
42
63
  end
@@ -54,6 +54,26 @@ module Zerobounce
54
54
  end
55
55
  end
56
56
 
57
+ # A failed API response.
58
+ #
59
+ # Carries the HTTP status so callers can tell a 403 (Cloudflare) from a
60
+ # 400/404 (API) or a 429 (the Warmup rate limit). The Warmup client raises
61
+ # it. The validation client raises the RestClient exception for an HTTP
62
+ # error status (status in #http_code), as it did up to 2.1.8, and this
63
+ # class, with the 2xx status (usually 200), for an error reported on a
64
+ # successful HTTP response; both carry the same short message. It is a
65
+ # RuntimeError, which those errors were before, so `rescue RuntimeError`
66
+ # still catches them.
67
+ class ApiResponseError < RuntimeError
68
+ # @return [Integer] the HTTP status of the failed response, or 0
69
+ attr_reader :status_code
70
+
71
+ def initialize(message, status_code = 0)
72
+ super(message)
73
+ @status_code = status_code.to_i
74
+ end
75
+ end
76
+
57
77
  # Server returned a 500 error.
58
78
  #
59
79
  # @author Aaron Frase
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'stringio'
5
+ require 'zerobounce/error'
6
+ require 'zerobounce/response_error'
5
7
 
6
8
  module Zerobounce
7
9
  # Bulk getfile response handling (v2): HTTP errors, JSON error bodies (including HTTP 200).
@@ -69,17 +71,13 @@ module Zerobounce
69
71
  body_str = response.body.to_s
70
72
  ct = (response.headers[:content_type] || response.headers['Content-Type'] || '').to_s
71
73
 
72
- if code > 299
73
- msg = if body_str.lstrip.start_with?('{')
74
- format_error_message(body_str)
75
- else
76
- body_str.empty? ? "HTTP #{code}" : body_str
77
- end
78
- raise msg
74
+ # An empty body is no result file: an error, as in the other SDKs.
75
+ if code > 299 || body_str.strip.empty? || ResponseError.html?(body_str, ct)
76
+ raise ApiResponseError.new(ResponseError.message_from_parts(code, ct, body_str), code)
79
77
  end
80
78
 
81
79
  if should_treat_as_error?(body_str, ct)
82
- raise format_error_message(body_str)
80
+ raise ApiResponseError.new(format_error_message(body_str), code)
83
81
  end
84
82
 
85
83
  io = StringIO.new(response.body)