Skip to content

Commit

Permalink
DEVX-6461: Improving Exceptions for API Errors (#287)
Browse files Browse the repository at this point in the history
* Adding additional context to some exception types.
  • Loading branch information
superchilled authored Sep 25, 2023
1 parent 44f2bb6 commit b3122cb
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 23 deletions.
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 7.16.0

* Adds HTTP Response context to some Exception types. [#287](https://github.com/Vonage/vonage-ruby-sdk/pull/287)

# 7.15.1

* Updates Meetings endpoints to `v1`. [#286](https://github.com/Vonage/vonage-ruby-sdk/pull/286)
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup].
* [Installation](#installation)
* [Usage](#usage)
* [Logging](#logging)
* [Exceptions](#exceptions)
* [Overriding the default hosts](#overriding-the-default-hosts)
* [JWT authentication](#jwt-authentication)
* [Webhook signatures](#webhook-signatures)
Expand Down Expand Up @@ -82,6 +83,46 @@ By default the library sets the logger to `Rails.logger` if it is defined.

To disable logging set the logger to `nil`.

## Exceptions

Where exceptions result from an error response from the Vonage API (HTTP responses that aren't ion the range `2xx` or `3xx`), the `Net::HTTPResponse` object will be available as a property of the `Exception` object via a `http_response` getter method (where there is no `Net::HTTPResponse` object associated with the exception, the value of `http_response` will be `nil`).

You can rescue the the exception to access the `http_response`, as well as use other getters provided for specific parts of the response. For example:

```ruby
begin
verification_request = client.verify2.start_verification(
brand: 'Acme',
workflow: [{channel: 'sms', to: '44700000000'}]
)
rescue Vonage::APIError => error
if error.http_response
error.http_response # => #<Net::HTTPUnauthorized 401 Unauthorized readbody=true>
error.http_response_code # => "401"
error.http_response_headers # => {"date"=>["Sun, 24 Sep 2023 11:08:47 GMT"], ...rest of headers}
error.http_response_body # => {"title"=>"Unauthorized", ...rest of body}
end
end
```

For certain legacy API products, such as the [SMS API](https://developer.vonage.com/en/messaging/sms/overview), [Verify v1 API](https://developer.vonage.com/en/verify/verify-v1/overview) and [Number Insight v1 API](https://developer.vonage.com/en/number-insight/overview), a `200` response is received even in situations where there is an API-related error. For exceptions raised in these situation, rather than a `Net::HTTPResponse` object, a `Vonage::Response` object will be made available as a property of the exception via a `response` getter method. The properties on this object will depend on the response data provided by the API endpoint. For example:

```ruby
begin
sms = client.sms.send(
from: 'Vonage',
to: '44700000000',
text: 'Hello World!'
)
rescue Vonage::Error => error
if error.is_a? Vonage::ServiceError
error.response # => #<Vonage::Response:0x0000555b2e49d4f8>
error.response.messages.first.status # => "4"
error.response.messages.first.error_text # => "Bad Credentials"
error.response.http_response # => #<Net::HTTPOK 200 OK readbody=true>
end
end
```

## Overriding the default hosts

Expand Down
1 change: 1 addition & 0 deletions lib/vonage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Vonage
loader = Zeitwerk::Loader.new
loader.tag = File.basename(__FILE__, '.rb')
loader.inflector.inflect({
'api_error' => 'APIError',
'dtmf' => 'DTMF',
'gsm7' => 'GSM7',
'http' => 'HTTP',
Expand Down
33 changes: 33 additions & 0 deletions lib/vonage/api_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# typed: strong
require "json"

module Vonage
class APIError < Error
extend T::Sig

sig { returns(Net::HTTPResponse) }
attr_reader :http_response

sig { params(message: T.nilable(String), http_response: T.nilable(Net::HTTPResponse)).void }
def initialize(message = nil, http_response: nil)
super(message)
@http_response = http_response
end

def http_response_code
return nil unless http_response
http_response.code
end

def http_response_headers
return nil unless http_response
http_response.to_hash
end

def http_response_body
return nil unless http_response
return {} unless http_response.content_type && http_response.content_type.include?("json")
::JSON.parse(http_response.body)
end
end
end
2 changes: 1 addition & 1 deletion lib/vonage/client_error.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# typed: strong

module Vonage
class ClientError < Error
class ClientError < APIError
end
end
39 changes: 21 additions & 18 deletions lib/vonage/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,30 @@ def self.parse(response)
when Net::HTTPServerError
ServerError
else
Error
APIError
end

message =
if response.content_type == "application/json"
hash = ::JSON.parse(response.body)
message = response.content_type.to_s.include?("json") ? set_message(response) : ""

if hash.key?("error_title")
hash["error_title"]
elsif hash.key?("error-code-label")
hash["error-code-label"]
elsif hash.key?("description")
hash["description"]
elsif hash.key?("message")
hash["message"]
elsif problem_details?(hash)
problem_details_message(hash)
end
end
exception_class.new(message, http_response: response)
end

def self.set_message(response)
hash = ::JSON.parse(response.body)

exception_class.new(message)
if hash.key?("error_title")
hash["error_title"]
elsif hash.key?("error-code-label")
hash["error-code-label"]
elsif hash.key?("description")
hash["description"]
elsif hash.key?("message")
hash["message"]
elsif problem_details?(hash)
problem_details_message(hash)
else
""
end
end

sig { params(hash: T::Hash[String, T.untyped]).returns(T::Boolean) }
Expand All @@ -57,7 +60,7 @@ def self.problem_details?(hash)

sig { params(hash: T::Hash[String, T.untyped]).returns(String) }
def self.problem_details_message(hash)
"#{hash["title"]}. #{hash["detail"]} See #{hash["type"]} for more info, or email support@nexmo.com if you have any questions."
"#{hash["title"]}. #{hash["detail"]} See #{hash["type"]} for more info, or email support@vonage.com if you have any questions."
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/vonage/server_error.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# typed: strong

module Vonage
class ServerError < Error
class ServerError < APIError
end
end
2 changes: 1 addition & 1 deletion lib/vonage/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# typed: strong

module Vonage
VERSION = "7.15.1"
VERSION = "7.16.0"
end
34 changes: 32 additions & 2 deletions test/vonage/errors_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,43 @@ def test_parse_with_401_response
error = Errors.parse(response(401))

assert_kind_of Vonage::AuthenticationError, error
assert_kind_of Vonage::APIError, error
assert_kind_of Net::HTTPUnauthorized, error.http_response
assert_equal "401", error.http_response_code
assert_kind_of Hash, error.http_response_headers
assert_kind_of Hash, error.http_response_body
end

def test_parse_with_4xx_response
error = Errors.parse(response(400))

assert_kind_of Vonage::ClientError, error
assert_kind_of Vonage::APIError, error
assert_kind_of Net::HTTPClientError, error.http_response
assert_equal "400", error.http_response_code
assert_kind_of Hash, error.http_response_headers
assert_kind_of Hash, error.http_response_body
end

def test_parse_with_5xx_response
error = Errors.parse(response(500))

assert_kind_of Vonage::ServerError, error
assert_kind_of Vonage::APIError, error
assert_kind_of Net::HTTPInternalServerError, error.http_response
assert_equal "500", error.http_response_code
assert_kind_of Hash, error.http_response_headers
assert_kind_of Hash, error.http_response_body
end

def test_parse_with_other_response
error = Errors.parse(response(101))

assert_kind_of Vonage::Error, error
assert_kind_of Vonage::APIError, error
assert_kind_of Net::HTTPInformation, error.http_response
assert_equal "101", error.http_response_code
assert_kind_of Hash, error.http_response_headers
assert_kind_of Hash, error.http_response_body
end

def test_parse_with_problem_response
Expand All @@ -55,7 +74,11 @@ def test_parse_with_problem_response
assert_includes error.message, 'You do not have enough credit.'
assert_includes error.message, 'Your current balance is 30, but that costs 50.'
assert_includes error.message, 'See https://example.com/Error#out-of-credit for more info,'
assert_includes error.message, 'or email [email protected] if you have any questions.'
assert_includes error.message, 'or email [email protected] if you have any questions.'
assert_equal error.http_response_body['type'], "https://example.com/Error#out-of-credit"
assert_equal error.http_response_body['title'], "You do not have enough credit"
assert_equal error.http_response_body['detail'], "Your current balance is 30, but that costs 50."
assert_equal error.http_response_body['instance'], "<trace_id>"
end

def test_parse_with_invalid_parameters_response
Expand All @@ -70,6 +93,9 @@ def test_parse_with_invalid_parameters_response
error = Errors.parse(error_response)

assert_includes error.message, 'Bad Request'
assert_equal error.http_response_body['type'], "BAD_REQUEST"
assert_equal error.http_response_body['error_title'], "Bad Request"
assert_equal error.http_response_body['invalid_parameters']['event_url'], "Is required."
end

def test_parse_with_code_and_description
Expand All @@ -83,6 +109,8 @@ def test_parse_with_code_and_description
error = Errors.parse(error_response)

assert_includes error.message, 'Endpoint does not exist, or you do not have access'
assert_equal error.http_response_body['code'], "http:error:not-found"
assert_equal error.http_response_body['description'], "Endpoint does not exist, or you do not have access."
end

def test_parse_with_error_label
Expand All @@ -96,5 +124,7 @@ def test_parse_with_error_label
error = Errors.parse(error_response)

assert_includes error.message, 'Numbers from this country can be requested from the Dashboard'
assert_equal error.http_response_body['error-code'], "420"
assert_equal error.http_response_body['error-code-label'], "Numbers from this country can be requested from the Dashboard (https://dashboard.nexmo.com/buy-numbers) as they require a valid local address to be provided before being purchased."
end
end

0 comments on commit b3122cb

Please sign in to comment.