Skip to content

[HELDENPMI-132] Add HTTP error taxonomy (status + network) with overridable mapping - #216

Merged
mitnal merged 13 commits into
mainfrom
claude/hopeful-knuth-60a193
Jun 16, 2026
Merged

mitnal merged 13 commits into
mainfrom
claude/hopeful-knuth-60a193

Conversation

@mitnal

@mitnal mitnal commented Jun 15, 2026 •

Copy link
Copy Markdown
Member

Issue Link: HELDENPMI-132 (supersedes HELDENPMI-131)

Adds an opt-in typed error taxonomy under NxtHttpClient::Error, so consumers stop hand-rolling the identical on(400)/on(401)/on(404)/on(422)/on(5xx)/on(0) boilerplate just to get a usable taxonomy for discard_on/retry_on and Sentry grouping.

Enabled with config.raise_error_taxonomy = true; off by default, so a plain gem bump is fully backward compatible — existing behavior (and raise_response_errors) is unchanged until a consumer opts in (the per-consumer rollout step where its boilerplate is deleted). Supersedes the original network-only scope (HELDENPMI-131); the interim NxtHttpClient::TransientError marker is removed before it ships.

Taxonomy (all inherit NxtHttpClient::Error)

HTTP status:

status class retryable?
400 Error::BadRequest no
401 Error::Unauthorized no
403 Error::Forbidden no
404 Error::NotFound no
422 Error::UnprocessableEntity no
429 Error::TooManyRequests up to consumer
other 4xx Error::ClientError no
5xx Error::ServerError yes

Network / code 0 (return_code-mapped):

return_code class retryable?
:operation_timedout Error::Timeout yes
:couldnt_connect Error::ConnectionFailed yes
:couldnt_resolve_host / _proxy Error::NameResolutionError yes
:ssl_connect_error + non-cert :ssl_* Error::TlsError yes
any other code-0 Error::NetworkError yes
cert verification (:peer_failed_verification, …) Error::CertificateError no

Retry pattern

retry_on NxtHttpClient::Error::NetworkError, NxtHttpClient::Error::ServerError

4xx (ClientError) not retried. CertificateError is a sibling of NetworkError (not a child) → excluded. TooManyRequests/429 left to consumers.

Overridable mapping

class MyService::Client < NxtHttpClient::Client
  map_error 422, MyService::Error::ValidationFailed  # raises a domain error that parses the body
end

Inherited by subclasses; overrides the default for that status.

Precedence

The taxonomy raises only via the callback_or_response fallback — never an on(:error) registration (which would collide with a consumer's own on(:error)). So a consumer's on(<code>)/on(:error)/on(:timed_out) always wins; you can enable the taxonomy for retries yet still handle e.g. a 404 inline.

Backward compatibility

  • Off by default (raise_error_taxonomy = false) → no behavior change on upgrade; raise_response_errors keeps raising the generic NxtHttpClient::Error.
  • ✅ rescue NxtHttpClient::Error / is_a? — all subclasses inherit from it.
  • ⚠️ Once enabled: exact-class checks (e.class ==) and error-message strings change → Sentry fingerprints regroup.

Not in this PR (follow-ups)

  • Release to GitHub Packages (2.2.0); bump consumers and set raise_error_taxonomy = true.
  • Delete per-status response_handler boilerplate; delete the user-service local NxtHttpClient::TransientError initializer (HELDENPMI-130).
  • Switch concerns to retry_on NetworkError, ServerError across admin-panel / claims / payment / insurance.

Tests

Full suite green (84 examples): status→class + return_code→class mapping, opt-in default-off, raising when enabled, consumer-callback precedence, and overridable map_error.

mitnal added 3 commits June 15, 2026 17:44
Add NxtHttpClient::TransientError marker + return_code-mapped subclasses under
NxtHttpClient::Error: NetworkError, Timeout, ConnectionFailed, NameResolutionError,
TlsError (transient) and CertificateError (cert verification, permanent). Consumers
can retry_on NxtHttpClient::TransientError without per-client on(0) wiring.

Code-0 (network failure) responses now raise the mapped error by default; opt out
via config.raise_network_errors = false. All subclasses inherit from
NxtHttpClient::Error, so rescue NxtHttpClient::Error keeps working.

HELDENPMI-131
Extend the default error taxonomy from network failures to HTTP status codes,
raised by default for unhandled non-success responses, so consumers stop
hand-rolling the identical per-status on(...) boilerplate:

- 4xx: ClientError + BadRequest/Unauthorized/Forbidden/NotFound/
  UnprocessableEntity/TooManyRequests
- 5xx: ServerError
- code-0/network: NetworkError subclasses + CertificateError

Retryable set is expressed by base classes:
retry_on NxtHttpClient::Error::NetworkError, NxtHttpClient::Error::ServerError.
CertificateError is a sibling of NetworkError (excluded); 429 is left to consumers.

Remove the NxtHttpClient::TransientError marker — base classes replace it. Add a
map_error(status, klass) DSL so a client can raise its own domain error for a
status. New config flags raise_status_errors / raise_network_errors (default true)
with opt-out; consumer callbacks always take precedence.

HELDENPMI-132
@mitnal mitnal changed the title [HELDENPMI-131] Add transient/network error classes mapped from libcurl return_code [HELDENPMI-132] Add HTTP error taxonomy (status + network) with overridable mapping Jun 15, 2026
mitnal added 5 commits June 16, 2026 10:24
Replace the default-on raise_status_errors / raise_network_errors flags with a
single use_error_taxonomy flag defaulting to false, so a gem bump is fully
backward compatible — existing behavior (and raise_response_errors) is unchanged
until a consumer opts in.

The taxonomy raises only via the callback_or_response fallback (never an
on(:error) registration, which would collide with a consumer's own on(:error)),
so consumer callbacks always take precedence. Restore raise_response_errors to
its original generic-Error behavior.

HELDENPMI-132
The json_response success handler parsed response_body unconditionally, so a
blank/204 body raised JSON::ParserError when .body was read. Return nil for a
blank body instead. Backward compatible — non-empty bodies parse as before.

HELDENPMI-132
When both are set the legacy on(:error) handler raised a generic error first,
shadowing the typed taxonomy. Skip registering it when use_error_taxonomy is on
so the taxonomy fallback raises the typed error. Document raise_response_errors
as superseded (kept for backward compatibility).

HELDENPMI-132
…se_response_errors legacy

Align the flag with the raise_ naming convention of raise_response_errors.

HELDENPMI-132

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in, typed NxtHttpClient::Error taxonomy (HTTP status + network/code-0) to standardize retry/discard handling and error grouping, while keeping existing behavior unchanged unless explicitly enabled.

Changes:

  • Introduces NxtHttpClient::Error.from_response + subclass taxonomy (ClientError/ServerError/NetworkError and specific status/return_code subclasses).
  • Adds config.raise_error_taxonomy and a map_error(status, klass) DSL override, with taxonomy raising occurring only as a fallback when no consumer callback handled the response.
  • Adjusts JSON response parsing to return nil for empty/204 bodies; bumps version/changelog and adds specs for mappings + opt-in behavior.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
spec/error_spec.rb Adds unit coverage for response→error-class mapping and retryable hierarchy expectations.
spec/configuration_spec.rb Adds coverage for 204 JSON body handling and taxonomy opt-in raising/precedence/map overrides.
README.md Documents new raise_error_taxonomy behavior, retry pattern, and map_error; notes 204 JSON body behavior.
lib/nxt_http_client/version.rb Bumps gem version to 2.2.0.
lib/nxt_http_client/error.rb Implements error taxonomy classes + mapping logic (status_error_class / network_error_class).
lib/nxt_http_client/config.rb Adds raise_error_taxonomy config default + documentation comments.
lib/nxt_http_client/client.rb Implements fallback raising of mapped errors; prevents legacy raise_response_errors handler when taxonomy enabled; makes JSON parsing nil-safe for empty bodies.
lib/nxt_http_client/client_dsl.rb Adds map_error + per-client error mapping inheritance via error_map.
Gemfile.lock Updates lockfile version to 2.2.0.
CHANGELOG.md Documents 2.2.0 changes (taxonomy + 204 JSON handling).
Comments suppressed due to low confidence (1)

lib/nxt_http_client/client.rb:188

  • Same concern as the taxonomy path: error.to_h includes request headers/options and body, which may contain secrets (Authorization/basic auth) and PII. Even if Sentry has server-side scrubbing, it’s safer to avoid sending these fields by default from the client library.
          handler.on(:error) do |response|
            error = NxtHttpClient::Error.new(response)
            ::Sentry.set_extras(http_error_details: error.to_h) if defined?(::Sentry)
            raise error

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/nxt_http_client/client.rb Resolved
Comment thread lib/nxt_http_client/client_dsl.rb Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
Comment thread CHANGELOG.md Resolved
mitnal added 3 commits June 16, 2026 11:15
- Validate map_error class is a NxtHttpClient::Error subclass (fail fast at
  registration instead of NoMethodError at raise time).
- Clarify README/CHANGELOG that the taxonomy raises only on 4xx/5xx/code-0 (not 3xx).
- Fix README: timeout_seconds (not timeouts).

HELDENPMI-132
to_h is sent to Sentry via set_extras, so bearer tokens and basic-auth
credentials were exposed. Redact them at the to_h boundary; the request_headers
/request_options accessors stay raw for local debugging.

HELDENPMI-132
…extras

Sentry deprecated Additional Data (set_extra) in favor of structured contexts.
Switch to set_context('http_error', error.to_h). Typhoeus isn't auto-instrumented
by Sentry, so the gem still attaches the details itself.

HELDENPMI-132
@mitnal mitnal self-assigned this Jun 16, 2026
@mitnal
mitnal marked this pull request as ready for review June 16, 2026 09:47
@mitnal
mitnal requested a review from chiibis June 16, 2026 09:48
def callback_or_response(response, response_handler)
callback = response_handler.callback_for_response(response)
callback && instance_exec(response, &callback) || response
return instance_exec(response, &callback) || response if callback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the method now returns response at the end by default do we still need the || response part here?

Are there situations where we would expect instance_exec(response, &callback) to return nil and would want to avoid returning it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need it to be able to continue having callbacks without a response returning the response.

HTTP header names are case-insensitive and HashWithIndifferentAccess does not
normalize case, so a consumer-set 'authorization' (non-canonical case) leaked
through Error#to_h. Match Authorization/Proxy-Authorization case-insensitively.

HELDENPMI-132
@mitnal
mitnal merged commit 3997ba9 into main Jun 16, 2026
3 checks passed
@mitnal
mitnal deleted the claude/hopeful-knuth-60a193 branch June 16, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants