Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit dc7d65b

Browse files
adsteeljonnyom
authored andcommitted
[bugfix] handle unexpected response bodies (#492)
* [refactor] add "m" for easier testing * [refactor] address minitest deprecations in request_spec.rb * [refactor] remove unnecessary raise on failure If the JSON parsing errors, `parsed_body` is `nil` and the following line raises the error. * [refactor] DRY code formatting in if/else * [refactor] allow access to pry in tests * [refactor] clarify public methods in Request This will help us better reason about which methods should have tests, and which methods other objects are depending on specific behavior from. * [refactor] clean up request specs Done as a step toward fixing a bug in `#parse_body` - Moves client test to client_spec.rb - Replaces parse_body tests with higher level tests - Makes parse_body private - Organizes and cleans up the request_spec file generally * [refactor] simplify response body parsing `parse_body` was doing three things: - checking for response errors - handling parse errors - actually parsing the response body We get some cleaner code by separating the parsing from the response state checking. * [bugfix] raise an error on unexpected response Some responses do not have an expected error code and have non-JSON bodies that fail to parse. The unexpected `nil` caused runtime errors. This fix raises an error on those responses with more information that will allow developers to find a path forward, either by more fixes to this gem or by handling the new error in their own code. I'm giving this version bump a patch level bump because it looks like all existing functionality is preserved. We will now simply get better information instead of `undefined method '[]' for nil:NilClass`. Addresses #491 * [refactor] inline private methods in Request.
1 parent 681bbc2 commit dc7d65b

File tree

8 files changed

+257
-162
lines changed

8 files changed

+257
-162
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,24 @@ intercom = Intercom::Client.new(token: ENV['AT'], handle_rate_limit: true)
560560
- **Send coherent history**. Make sure each individual commit in your pull
561561
request is meaningful. If you had to make multiple intermediate commits while
562562
developing, please squash them before sending them to us.
563+
564+
### Development
565+
566+
#### Running tests
567+
568+
```bash
569+
# all tests
570+
bundle exec spec
571+
572+
# unit tests
573+
bundle exec spec:unit
574+
575+
# integration tests
576+
bundle exec spec:integration
577+
578+
# single test file
579+
bundle exec m spec/unit/intercom/job_spec.rb
580+
581+
# single test
582+
bundle exec m spec/unit/intercom/job_spec.rb:49
583+
```

intercom.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
1919
spec.require_paths = ["lib"]
2020

2121
spec.add_development_dependency 'minitest', '~> 5.4'
22+
spec.add_development_dependency "m", "~> 1.5.0"
2223
spec.add_development_dependency 'rake', '~> 10.3'
2324
spec.add_development_dependency 'mocha', '~> 1.0'
2425
spec.add_development_dependency "fakeweb", ["~> 1.3"]

lib/intercom/errors.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ class BadRequestError < IntercomError; end
6363
# Raised when you have exceeded the API rate limit
6464
class RateLimitExceeded < IntercomError; end
6565

66+
# Raised when some attribute of the response cannot be handled
67+
class UnexpectedResponseError < IntercomError; end
68+
6669
# Raised when the request throws an error not accounted for
6770
class UnexpectedError < IntercomError; end
6871

lib/intercom/request.rb

Lines changed: 104 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,48 @@
33

44
module Intercom
55
class Request
6-
attr_accessor :path, :net_http_method, :rate_limit_details, :handle_rate_limit
7-
8-
def initialize(path, net_http_method)
9-
self.path = path
10-
self.net_http_method = net_http_method
11-
self.handle_rate_limit = false
12-
end
13-
14-
def set_common_headers(method, base_uri)
15-
method.add_field('AcceptEncoding', 'gzip, deflate')
16-
end
17-
18-
def set_basic_auth(method, username, secret)
19-
method.basic_auth(CGI.unescape(username), CGI.unescape(secret))
20-
end
6+
class << self
7+
def get(path, params)
8+
new(path, Net::HTTP::Get.new(append_query_string_to_url(path, params), default_headers))
9+
end
2110

22-
def set_api_version(method, api_version)
23-
method.add_field('Intercom-Version', api_version)
24-
end
11+
def post(path, form_data)
12+
new(path, method_with_body(Net::HTTP::Post, path, form_data))
13+
end
2514

26-
def self.get(path, params)
27-
new(path, Net::HTTP::Get.new(append_query_string_to_url(path, params), default_headers))
28-
end
15+
def delete(path, params)
16+
new(path, method_with_body(Net::HTTP::Delete, path, params))
17+
end
2918

30-
def self.post(path, form_data)
31-
new(path, method_with_body(Net::HTTP::Post, path, form_data))
32-
end
19+
def put(path, form_data)
20+
new(path, method_with_body(Net::HTTP::Put, path, form_data))
21+
end
3322

34-
def self.delete(path, params)
35-
new(path, method_with_body(Net::HTTP::Delete, path, params))
36-
end
23+
private def method_with_body(http_method, path, params)
24+
request = http_method.send(:new, path, default_headers)
25+
request.body = params.to_json
26+
request["Content-Type"] = "application/json"
27+
request
28+
end
3729

38-
def self.put(path, form_data)
39-
new(path, method_with_body(Net::HTTP::Put, path, form_data))
40-
end
30+
private def default_headers
31+
{'Accept-Encoding' => 'gzip, deflate', 'Accept' => 'application/vnd.intercom.3+json', 'User-Agent' => "Intercom-Ruby/#{Intercom::VERSION}"}
32+
end
4133

42-
def self.method_with_body(http_method, path, params)
43-
request = http_method.send(:new, path, default_headers)
44-
request.body = params.to_json
45-
request["Content-Type"] = "application/json"
46-
request
34+
private def append_query_string_to_url(url, params)
35+
return url if params.empty?
36+
query_string = params.map { |k, v| "#{k.to_s}=#{CGI::escape(v.to_s)}" }.join('&')
37+
url + "?#{query_string}"
38+
end
4739
end
4840

49-
def self.default_headers
50-
{'Accept-Encoding' => 'gzip, deflate', 'Accept' => 'application/vnd.intercom.3+json', 'User-Agent' => "Intercom-Ruby/#{Intercom::VERSION}"}
41+
def initialize(path, net_http_method)
42+
self.path = path
43+
self.net_http_method = net_http_method
44+
self.handle_rate_limit = false
5145
end
5246

53-
def client(uri, read_timeout:, open_timeout:)
54-
net = Net::HTTP.new(uri.host, uri.port)
55-
if uri.is_a?(URI::HTTPS)
56-
net.use_ssl = true
57-
net.verify_mode = OpenSSL::SSL::VERIFY_PEER
58-
net.ca_file = File.join(File.dirname(__FILE__), '../data/cacert.pem')
59-
end
60-
net.read_timeout = read_timeout
61-
net.open_timeout = open_timeout
62-
net
63-
end
47+
attr_accessor :handle_rate_limit
6448

6549
def execute(target_base_url=nil, username:, secret: nil, read_timeout: 90, open_timeout: 30, api_version: nil)
6650
retries = 3
@@ -72,10 +56,16 @@ def execute(target_base_url=nil, username:, secret: nil, read_timeout: 90, open_
7256
client(base_uri, read_timeout: read_timeout, open_timeout: open_timeout).start do |http|
7357
begin
7458
response = http.request(net_http_method)
59+
7560
set_rate_limit_details(response)
76-
decoded_body = decode_body(response)
77-
parsed_body = parse_body(decoded_body, response)
7861
raise_errors_on_failure(response)
62+
63+
parsed_body = extract_response_body(response)
64+
65+
return nil if parsed_body.nil?
66+
67+
raise_application_errors_on_failure(parsed_body, response.code.to_i) if parsed_body['type'] == 'error.list'
68+
7969
parsed_body
8070
rescue Intercom::RateLimitExceeded => e
8171
if @handle_rate_limit
@@ -98,55 +88,91 @@ def execute(target_base_url=nil, username:, secret: nil, read_timeout: 90, open_
9888
end
9989
end
10090

101-
def decode_body(response)
102-
decode(response['content-encoding'], response.body)
103-
end
91+
attr_accessor :path,
92+
:net_http_method,
93+
:rate_limit_details
10494

105-
def parse_body(decoded_body, response)
106-
parsed_body = nil
107-
return parsed_body if decoded_body.nil? || decoded_body.strip.empty?
108-
begin
109-
parsed_body = JSON.parse(decoded_body)
110-
rescue JSON::ParserError => _
111-
raise_errors_on_failure(response)
95+
private :path,
96+
:net_http_method,
97+
:rate_limit_details
98+
99+
private def client(uri, read_timeout:, open_timeout:)
100+
net = Net::HTTP.new(uri.host, uri.port)
101+
if uri.is_a?(URI::HTTPS)
102+
net.use_ssl = true
103+
net.verify_mode = OpenSSL::SSL::VERIFY_PEER
104+
net.ca_file = File.join(File.dirname(__FILE__), '../data/cacert.pem')
112105
end
113-
raise_errors_on_failure(response) if parsed_body.nil?
114-
raise_application_errors_on_failure(parsed_body, response.code.to_i) if parsed_body['type'] == 'error.list'
115-
parsed_body
106+
net.read_timeout = read_timeout
107+
net.open_timeout = open_timeout
108+
net
116109
end
117110

118-
def set_rate_limit_details(response)
111+
private def extract_response_body(response)
112+
decoded_body = decode(response['content-encoding'], response.body)
113+
114+
json_parse_response(decoded_body, response.code)
115+
end
116+
117+
private def decode(content_encoding, body)
118+
return body if (!body) || body.empty? || content_encoding != 'gzip'
119+
Zlib::GzipReader.new(StringIO.new(body)).read.force_encoding("utf-8")
120+
end
121+
122+
private def json_parse_response(str, code)
123+
return nil if str.to_s.empty?
124+
125+
JSON.parse(str)
126+
rescue JSON::ParserError
127+
msg = <<~MSG.gsub(/[[:space:]]+/, " ").strip # #squish from ActiveSuppor
128+
Expected a JSON response body. Instead got '#{str}'
129+
with status code '#{code}'.
130+
MSG
131+
132+
raise UnexpectedResponseError, msg
133+
end
134+
135+
private def set_rate_limit_details(response)
119136
rate_limit_details = {}
120137
rate_limit_details[:limit] = response['X-RateLimit-Limit'].to_i if response['X-RateLimit-Limit']
121138
rate_limit_details[:remaining] = response['X-RateLimit-Remaining'].to_i if response['X-RateLimit-Remaining']
122139
rate_limit_details[:reset_at] = Time.at(response['X-RateLimit-Reset'].to_i) if response['X-RateLimit-Reset']
123140
@rate_limit_details = rate_limit_details
124141
end
125142

126-
def decode(content_encoding, body)
127-
return body if (!body) || body.empty? || content_encoding != 'gzip'
128-
Zlib::GzipReader.new(StringIO.new(body)).read.force_encoding("utf-8")
143+
private def set_common_headers(method, base_uri)
144+
method.add_field('AcceptEncoding', 'gzip, deflate')
129145
end
130146

131-
def raise_errors_on_failure(res)
132-
if res.code.to_i.eql?(404)
147+
private def set_basic_auth(method, username, secret)
148+
method.basic_auth(CGI.unescape(username), CGI.unescape(secret))
149+
end
150+
151+
private def set_api_version(method, api_version)
152+
method.add_field('Intercom-Version', api_version)
153+
end
154+
155+
private def raise_errors_on_failure(res)
156+
code = res.code.to_i
157+
158+
if code == 404
133159
raise Intercom::ResourceNotFound.new('Resource Not Found')
134-
elsif res.code.to_i.eql?(401)
160+
elsif code == 401
135161
raise Intercom::AuthenticationError.new('Unauthorized')
136-
elsif res.code.to_i.eql?(403)
162+
elsif code == 403
137163
raise Intercom::AuthenticationError.new('Forbidden')
138-
elsif res.code.to_i.eql?(429)
164+
elsif code == 429
139165
raise Intercom::RateLimitExceeded.new('Rate Limit Exceeded')
140-
elsif res.code.to_i.eql?(500)
166+
elsif code == 500
141167
raise Intercom::ServerError.new('Server Error')
142-
elsif res.code.to_i.eql?(502)
168+
elsif code == 502
143169
raise Intercom::BadGatewayError.new('Bad Gateway Error')
144-
elsif res.code.to_i.eql?(503)
170+
elsif code == 503
145171
raise Intercom::ServiceUnavailableError.new('Service Unavailable')
146172
end
147173
end
148174

149-
def raise_application_errors_on_failure(error_list_details, http_code)
175+
private def raise_application_errors_on_failure(error_list_details, http_code)
150176
# Currently, we don't support multiple errors
151177
error_details = error_list_details['errors'].first
152178
error_code = error_details['type'] || error_details['code']
@@ -198,18 +224,12 @@ def raise_application_errors_on_failure(error_list_details, http_code)
198224
end
199225
end
200226

201-
def message_for_unexpected_error_with_type(error_details, parsed_http_code)
227+
private def message_for_unexpected_error_with_type(error_details, parsed_http_code)
202228
"The error of type '#{error_details['type']}' is not recognized. It occurred with the message: #{error_details['message']} and http_code: '#{parsed_http_code}'. Please contact Intercom with these details."
203229
end
204230

205-
def message_for_unexpected_error_without_type(error_details, parsed_http_code)
231+
private def message_for_unexpected_error_without_type(error_details, parsed_http_code)
206232
"An unexpected error occured. It occurred with the message: #{error_details['message']} and http_code: '#{parsed_http_code}'. Please contact Intercom with these details."
207233
end
208-
209-
def self.append_query_string_to_url(url, params)
210-
return url if params.empty?
211-
query_string = params.map { |k, v| "#{k.to_s}=#{CGI::escape(v.to_s)}" }.join('&')
212-
url + "?#{query_string}"
213-
end
214234
end
215235
end

lib/intercom/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module Intercom #:nodoc:
2-
VERSION = "3.9.0"
2+
VERSION = "3.9.1"
33
end

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'mocha/setup'
44
require 'webmock'
55
require 'time'
6+
require 'pry'
67
include WebMock::API
78

89
def test_customer(email="[email protected]")

spec/unit/intercom/client_spec.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@ module Intercom
44
describe Client do
55
let(:app_id) { 'myappid' }
66
let(:api_key) { 'myapikey' }
7-
let(:client) { Client.new(app_id: app_id, api_key: api_key) }
7+
let(:client) do
8+
Client.new(
9+
app_id: app_id,
10+
api_key: api_key,
11+
handle_rate_limit: true
12+
)
13+
end
814

915
it 'should set the base url' do
1016
client.base_url.must_equal('https://api.intercom.io')
1117
end
1218

19+
it 'should have handle_rate_limit set' do
20+
_(client.handle_rate_limit).must_equal(true)
21+
end
22+
1323
it 'should be able to change the base url' do
1424
prev = client.options(Intercom::Client.set_base_url('https://mymockintercom.io'))
1525
client.base_url.must_equal('https://mymockintercom.io')

0 commit comments

Comments
 (0)