Skip to content

Commit 3997ba9

Browse files
authored
feat: opt-in HTTP error taxonomy with overridable mapping (#216)
Adds an opt-in (config.raise_error_taxonomy, default false) typed NxtHttpClient::Error taxonomy for HTTP status + network/code-0 failures, a map_error DSL to override the mapping per client, a json_response empty/204-body guard, credential redaction in Error#to_h, and Sentry reporting via set_context. Backwards compatible (taxonomy off by default). HELDENPMI-132.
1 parent 9b40a69 commit 3997ba9

10 files changed

Lines changed: 429 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
# v2.2.0 2026-06-15
2+
- Report HTTP error details to Sentry via the structured `set_context('http_error', …)` instead of the
3+
deprecated `set_extras(http_error_details: …)`. (Typhoeus is not auto-instrumented by Sentry, so the gem
4+
attaches this itself.)
5+
- `Error#to_h` now redacts the `Authorization` header and basic-auth `userpwd` (it is sent to Sentry).
6+
- `json_response` now returns `nil` for an empty/204 body instead of raising `JSON::ParserError`.
7+
- Add an opt-in error taxonomy under `NxtHttpClient::Error`. With `config.raise_error_taxonomy = true` the client
8+
raises a typed subclass for an unhandled 4xx/5xx/code-0 response instead of returning it:
9+
- HTTP status: `ClientError` with `BadRequest` (400), `Unauthorized` (401), `Forbidden` (403),
10+
`NotFound` (404), `UnprocessableEntity` (422), `TooManyRequests` (429); `ServerError` (5xx).
11+
- Network (`return_code`-mapped code-0): `NetworkError` with `Timeout`, `ConnectionFailed`,
12+
`NameResolutionError`, `TlsError`; `CertificateError` (cert verification — a sibling, not a child).
13+
- Retryable errors share base classes — `retry_on NxtHttpClient::Error::NetworkError, NxtHttpClient::Error::ServerError`.
14+
4xx, `CertificateError` and 429 are excluded (429 retry policy is left to consumers).
15+
- `map_error(status, klass)` DSL to override the mapping per client (e.g. a domain `ValidationFailed` that
16+
parses the body); inherited by subclasses.
17+
- `config.raise_error_taxonomy` defaults to `false`, so the upgrade is backwards compatible — existing behavior
18+
(and `raise_response_errors`) is unchanged until you opt in. A consumer's own `on(<code>)`/`on(:error)`/
19+
`on(:timed_out)` callback always takes precedence over the taxonomy.
20+
121
# v2.1.0 2024-06-05
222
- Bump dependencies
323

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
nxt_http_client (2.1.1)
4+
nxt_http_client (2.2.0)
55
activesupport
66
nxt_registry
77
typhoeus

README.md

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class UserServiceClient < NxtHttpClient::Client
4848
# Note: This error handler is set by default when you use
4949
# config.raise_response_errors = true
5050
handler.on(:error) do |response|
51-
Sentry.set_extras(http_error_details: error.to_h)
51+
Sentry.set_context('http_error', error.to_h)
5252
raise StandardError, "I can't handle this: #{response.code}"
5353
end
5454
end
@@ -118,12 +118,16 @@ Register your default request options on the class level. Available options are:
118118
- `base_url=`
119119
- `x_request_id_proc=`
120120
- `json_request=`: Shorthand to set the Content-Type request header to JSON and automatically convert request bodies to JSON
121-
- `json_response=`: Shorthand to set the Accept request header and automatically convert success response bodies to JSON
122-
- `raise_response_errors=`: Makes the client raise a `NxtHttpClient::Error` for a non-success response.
123-
You can also do this manually by setting a response_handler.
121+
- `json_response=`: Shorthand to set the Accept request header and automatically convert success response bodies to JSON (an empty/204 body becomes `nil`)
122+
- `raise_response_errors=`: Makes the client raise a generic `NxtHttpClient::Error` for a non-success response.
123+
Superseded by `raise_error_taxonomy` (which raises typed errors and takes precedence when both are set); kept for
124+
backward compatibility.
125+
- `raise_error_taxonomy=`: Defaults to `false`. Opt in to raise the mapped `NxtHttpClient::Error` taxonomy
126+
(`ClientError`/`ServerError`/`NetworkError` subclasses) on an unhandled 4xx/5xx/code-0 response instead of
127+
returning it. See [Error taxonomy](#error-taxonomy).
124128
- `bearer_auth=`: Set a bearer token to be sent in the Authorization header
125129
- `basic_auth=`: Pass a Hash containing `:username` and `:password`, to be sent as Basic credentials in the Authorization header
126-
- `timeouts(total:, connect: nil)`: Configure timeouts
130+
- `timeout_seconds(total:, connect: nil)`: Configure timeouts
127131
128132
### response_handler
129133
@@ -215,13 +219,84 @@ To set a timeout, use the `timeout_seconds` config method:
215219
configure do |config|
216220
config.timeout_seconds(total: 10)
217221
# You can also set a connect timeout
218-
config.timeout_seconds(total: 10, connecttimeout: 2)
222+
config.timeout_seconds(total: 10, connect: 2)
219223
end
220224
```
221225
222226
NxtHttpClient::Error exposes the `timed_out?` method from `Typhoeus::Response`, so you can check if an error is raised due to a timeout.
223227
This is useful when setting a custom timeout value in your configuration.
224228
229+
### Error taxonomy
230+
231+
Set `config.raise_error_taxonomy = true` and the client raises a typed subclass of `NxtHttpClient::Error` for an
232+
unhandled 4xx, 5xx, or code-0 (network) response, so you no longer need to hand-roll per-status
233+
`on(400)`/`on(422)`/`on(5xx)`/`on(0)` handlers just to get a usable taxonomy. (3xx and other codes are returned
234+
as before.) It is **off by default** (the client returns the response); all classes inherit from
235+
`NxtHttpClient::Error`, so existing `rescue NxtHttpClient::Error` handlers keep working.
236+
237+
**HTTP status:**
238+
239+
| status | class | retryable? |
240+
|---------------|---------------------------------------------|------------|
241+
| 400 | `NxtHttpClient::Error::BadRequest` | no |
242+
| 401 | `NxtHttpClient::Error::Unauthorized` | no |
243+
| 403 | `NxtHttpClient::Error::Forbidden` | no |
244+
| 404 | `NxtHttpClient::Error::NotFound` | no |
245+
| 422 | `NxtHttpClient::Error::UnprocessableEntity` | no |
246+
| 429 | `NxtHttpClient::Error::TooManyRequests` | up to you |
247+
| other 4xx | `NxtHttpClient::Error::ClientError` | no |
248+
| 5xx | `NxtHttpClient::Error::ServerError` | yes |
249+
250+
**Network / code 0.** Typhoeus/libcurl surfaces network failures and timeouts as a response with HTTP **code 0**
251+
(no response received); the real cause lives in libcurl's `return_code`:
252+
253+
| libcurl `return_code` | class | retryable? |
254+
|----------------------------------------------------|---------------------------------------------|------------|
255+
| `:operation_timedout` | `NxtHttpClient::Error::Timeout` | yes |
256+
| `:couldnt_connect` | `NxtHttpClient::Error::ConnectionFailed` | yes |
257+
| `:couldnt_resolve_host` / `:couldnt_resolve_proxy` | `NxtHttpClient::Error::NameResolutionError` | yes |
258+
| `:ssl_connect_error` and other non-cert `:ssl_*` | `NxtHttpClient::Error::TlsError` | yes |
259+
| any other code-0 | `NxtHttpClient::Error::NetworkError` | yes |
260+
| cert verification (`:peer_failed_verification`, …) | `NxtHttpClient::Error::CertificateError` | no |
261+
262+
#### Retrying
263+
264+
The retryable errors share two base classes, so a job retries them in one place:
265+
266+
```ruby
267+
retry_on NxtHttpClient::Error::NetworkError, NxtHttpClient::Error::ServerError
268+
```
269+
270+
`ClientError` (4xx) is not retried — those are caller mistakes. `CertificateError` is a **sibling** of `NetworkError`
271+
(not a child), so it is excluded from `retry_on NetworkError` — a failed cert/CA verification is permanent.
272+
`TooManyRequests` (429) is left out of the retryable set; add your own `retry_on NxtHttpClient::Error::TooManyRequests`
273+
(ideally honoring `Retry-After`) if you want it.
274+
275+
#### Precedence and opting out
276+
277+
A consumer's own `on(<code>)` / `on(:error)` / `on(:timed_out)` callback always takes precedence; the raise only
278+
fires when nothing else handled the response. So you can enable the taxonomy for retries yet still handle, say, a
279+
404 inline with your own `on(404)`.
280+
281+
#### Domain-typed errors
282+
283+
Map a status to your own error class with `map_error` to get a domain error that parses the response body. It is
284+
inherited by subclasses and overrides the default for that status. `map_error` only takes effect when
285+
`raise_error_taxonomy` is enabled — it customizes the taxonomy, it does not enable raising on its own:
286+
287+
```ruby
288+
class MyService::Client < NxtHttpClient::Client
289+
configure { |config| config.raise_error_taxonomy = true }
290+
map_error 422, MyService::Error::ValidationFailed
291+
end
292+
293+
class MyService::Error::ValidationFailed < NxtHttpClient::Error
294+
def default_message
295+
body.dig('errors', 0, 'detail') # `body` parses JSON response bodies for you
296+
end
297+
end
298+
```
299+
225300
### Logging
226301
227302
NxtHttpClient also comes with a log method on the class level that you can pass a proc if you want to log your request.

lib/nxt_http_client/client.rb

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,25 @@ def url_without_duplicated_hashes(url)
144144

145145
def callback_or_response(response, response_handler)
146146
callback = response_handler.callback_for_response(response)
147-
callback && instance_exec(response, &callback) || response
147+
return instance_exec(response, &callback) || response if callback
148+
149+
return raise_mapped_error(response) if raise_mapped_error?(response)
150+
151+
response
152+
end
153+
154+
# Reached only when no consumer callback matched, so a consumer on(<code>)/on(:error) keeps precedence.
155+
def raise_mapped_error?(response)
156+
return false unless config.raise_error_taxonomy
157+
158+
code = response.code.to_i
159+
code.zero? || (400..599).cover?(code)
160+
end
161+
162+
def raise_mapped_error(response)
163+
error = self.class.error_class_for(response).new(response)
164+
::Sentry.set_context('http_error', error.to_h) if defined?(::Sentry)
165+
raise error
148166
end
149167

150168
def build_response_handler(handler, &block)
@@ -153,17 +171,20 @@ def build_response_handler(handler, &block)
153171
if config.json_response
154172
response_handler.configure do |handler|
155173
handler.on(:success) do |response|
156-
response.define_singleton_method(:body) { JSON(response.response_body) }
174+
# nil for a blank/204 body — parsing "" would raise JSON::ParserError
175+
response.define_singleton_method(:body) { response.response_body.presence && JSON(response.response_body) }
157176
response
158177
end
159178
end
160179
end
161180

162-
if config.raise_response_errors
181+
# Legacy generic-error raising. Superseded by raise_error_taxonomy (typed), which takes precedence here
182+
# via the callback_or_response fallback — so don't also register this shadowing on(:error) handler.
183+
if config.raise_response_errors && !config.raise_error_taxonomy
163184
response_handler.configure do |handler|
164185
handler.on(:error) do |response|
165186
error = NxtHttpClient::Error.new(response)
166-
::Sentry.set_extras(http_error_details: error.to_h) if defined?(::Sentry)
187+
::Sentry.set_context('http_error', error.to_h) if defined?(::Sentry)
167188
raise error
168189
end
169190
end

lib/nxt_http_client/client_dsl.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ def response_handler(handler = Undefined.new, &block)
5252
@response_handler
5353
end
5454

55+
# Override the taxonomy's error class for a status, e.g. `map_error 422, MyService::ValidationFailed`.
56+
# Only takes effect when config.raise_error_taxonomy is enabled.
57+
def map_error(status, error_class)
58+
unless error_class.is_a?(Class) && error_class <= NxtHttpClient::Error
59+
raise ArgumentError, "#{error_class.inspect} must be a subclass of NxtHttpClient::Error"
60+
end
61+
62+
error_map[Integer(status)] = error_class
63+
end
64+
65+
def error_map
66+
@error_map ||= dup_option_from_ancestor(:@error_map) { {} }
67+
end
68+
69+
def error_class_for(response)
70+
error_map[response.code.to_i] || NxtHttpClient::Error.error_class_for(response)
71+
end
72+
5573
private
5674

5775
def client_ancestors

lib/nxt_http_client/config.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ module NxtHttpClient
88
json_request: false,
99
# Helper to set the Accept request header and automatically convert success response bodies to JSON
1010
json_response: false,
11-
raise_response_errors: false,
11+
raise_response_errors: false, # Legacy: raises a generic NxtHttpClient::Error. Superseded by raise_error_taxonomy.
12+
# Opt in (default false) to raise the mapped NxtHttpClient::Error taxonomy on an unhandled
13+
# 4xx/5xx/code-0 response instead of returning it. See README "Error taxonomy".
14+
raise_error_taxonomy: false,
1215

1316
bearer_auth: nil,
1417
basic_auth: nil,

lib/nxt_http_client/error.rb

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,49 @@
11
module NxtHttpClient
22
class Error < StandardError
3+
# Cert/trust failures — mapped to non-retryable CertificateError, kept out of the generic TlsError.
4+
CERTIFICATE_RETURN_CODES = %i[
5+
peer_failed_verification ssl_certproblem ssl_cacert_badfile ssl_issuer_error ssl_crl_badfile
6+
].freeze
7+
8+
REDACTED = '[REDACTED]'
9+
SENSITIVE_HEADERS = %w[Authorization Proxy-Authorization].freeze
10+
11+
def self.from_response(response, message = nil)
12+
error_class_for(response).new(response, message)
13+
end
14+
15+
def self.error_class_for(response)
16+
code = response.respond_to?(:code) ? response.code.to_i : 0
17+
return network_error_class(response.respond_to?(:return_code) ? response.return_code : nil) if code.zero?
18+
19+
status_error_class(code)
20+
end
21+
22+
def self.status_error_class(code)
23+
case code
24+
when 400 then BadRequest
25+
when 401 then Unauthorized
26+
when 403 then Forbidden
27+
when 404 then NotFound
28+
when 422 then UnprocessableEntity
29+
when 429 then TooManyRequests
30+
when 400..499 then ClientError
31+
when 500..599 then ServerError
32+
else self # 3xx etc. → base Error
33+
end
34+
end
35+
36+
def self.network_error_class(return_code)
37+
case return_code
38+
when :operation_timedout then Timeout
39+
when :couldnt_connect then ConnectionFailed
40+
when :couldnt_resolve_host, :couldnt_resolve_proxy then NameResolutionError
41+
when *CERTIFICATE_RETURN_CODES then CertificateError
42+
else
43+
return_code.to_s.include?('ssl') ? TlsError : NetworkError
44+
end
45+
end
46+
347
def initialize(response, message = nil)
448
@response = response.blank? ? Typhoeus::Response.new : response
549
@id = SecureRandom.uuid
@@ -22,9 +66,9 @@ def to_h
2266
id: id,
2367
url: url,
2468
response_code: response_code,
25-
request_options: request_options,
69+
request_options: redact_credentials(request_options),
2670
response_headers: response_headers,
27-
request_headers: request_headers,
71+
request_headers: redact_authorization(request_headers),
2872
body: body,
2973
x_request_id: x_request_id
3074
}
@@ -77,5 +121,46 @@ def response_headers
77121
def response_content_type
78122
response_headers['Content-Type']
79123
end
124+
125+
private
126+
127+
# Keep Authorization tokens / basic-auth creds out of serialized output (to_h reaches Sentry).
128+
def redact_credentials(options)
129+
options = options.merge('userpwd' => REDACTED) if options.key?('userpwd')
130+
return options unless options['headers'].respond_to?(:key?)
131+
132+
options.merge('headers' => redact_authorization(options['headers']))
133+
end
134+
135+
def redact_authorization(headers)
136+
return headers unless headers.respond_to?(:keys)
137+
138+
# HTTP header names are case-insensitive, and HashWithIndifferentAccess does not normalize case.
139+
sensitive = headers.keys.select { |key| SENSITIVE_HEADERS.any? { |name| key.to_s.casecmp?(name) } }
140+
return headers if sensitive.empty?
141+
142+
headers.merge(sensitive.index_with { REDACTED })
143+
end
144+
145+
public
146+
147+
class ClientError < self; end
148+
class BadRequest < ClientError; end # 400
149+
class Unauthorized < ClientError; end # 401
150+
class Forbidden < ClientError; end # 403
151+
class NotFound < ClientError; end # 404
152+
class UnprocessableEntity < ClientError; end # 422
153+
class TooManyRequests < ClientError; end # 429
154+
155+
class ServerError < self; end
156+
157+
class NetworkError < self; end # code 0 (no HTTP response)
158+
class Timeout < NetworkError; end # :operation_timedout
159+
class ConnectionFailed < NetworkError; end # :couldnt_connect
160+
class NameResolutionError < NetworkError; end # :couldnt_resolve_host / :couldnt_resolve_proxy
161+
class TlsError < NetworkError; end # :ssl_connect_error and other non-cert :ssl_*
162+
163+
# Sibling of NetworkError, not a child, so it's excluded from `retry_on NetworkError`.
164+
class CertificateError < self; end
80165
end
81166
end

lib/nxt_http_client/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module NxtHttpClient
2-
VERSION = "2.1.1"
2+
VERSION = "2.2.0"
33
end

0 commit comments

Comments
 (0)