From 12bf28a1d58055ffabf0ae3e0c53fdd2b69913a2 Mon Sep 17 00:00:00 2001 From: Omkar Moghe Date: Mon, 4 May 2026 12:16:23 -0700 Subject: [PATCH 1/4] Raise specific errors --- lib/http/errors.rb | 46 ++++++++++++++++++++++++++++++ lib/http/features/raise_error.rb | 49 +++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/lib/http/errors.rb b/lib/http/errors.rb index 5ff410df..2d0821fa 100644 --- a/lib/http/errors.rb +++ b/lib/http/errors.rb @@ -49,6 +49,52 @@ def initialize(response) end end + # Client errors 4xx + class ClientError < StatusError; end + class BadRequestError < ClientError; end + class UnauthorizedError < ClientError; end + class PaymentRequiredError < ClientError; end + class ForbiddenError < ClientError; end + class NotFoundError < ClientError; end + class MethodNotAllowedError < ClientError; end + class NotAcceptableError < ClientError; end + class ProxyAuthenticationRequiredError < ClientError; end + class RequestTimeoutError < ClientError; end + class ConflictError < ClientError; end + class GoneError < ClientError; end + class LengthRequiredError < ClientError; end + class PreconditionFailedError < ClientError; end + class ContentTooLargeError < ClientError; end + class UriTooLongError < ClientError; end + class UnsupportedMediaTypeError < ClientError; end + class RangeNotSatisfiableError < ClientError; end + class ExpectationFailedError < ClientError; end + class ImATeapotError < ClientError; end + class MisdirectedRequestError < ClientError; end + class UnprocessableContentError < ClientError; end + class LockedError < ClientError; end + class FailedDependencyError < ClientError; end + class TooEarlyError < ClientError; end + class UpgradeRequiredError < ClientError; end + class PreconditionRequiredError < ClientError; end + class TooManyRequestsError < ClientError; end + class RequestHeaderFieldsTooLargeError < ClientError; end + class UnavailableForLegalReasonsError < ClientError; end + + # Server errors 5xx + class ServerError < StatusError; end + class InternalServerError < ServerError; end + class NotImplementedError < ServerError; end + class BadGatewayError < ServerError; end + class ServiceUnavailableError < ServerError; end + class GatewayTimeoutError < ServerError; end + class HttpVersionNotSupportedError < ServerError; end + class VariantAlsoNegotiatesError < ServerError; end + class InsufficientStorageError < ServerError; end + class LoopDetectedError < ServerError; end + class NotExtendedError < ServerError; end + class NetworkAuthenticationRequiredError < ServerError; end + # Raised when `Response#parse` fails due to any underlying reason (unexpected # MIME type, or decoder fails). See `Exception#cause` for the original exception. class ParseError < ResponseError; end diff --git a/lib/http/features/raise_error.rb b/lib/http/features/raise_error.rb index 9236a61f..01886ce3 100644 --- a/lib/http/features/raise_error.rb +++ b/lib/http/features/raise_error.rb @@ -28,7 +28,54 @@ def wrap_response(response) return response if response.code < 400 return response if @ignore.include?(response.code) - raise StatusError, response + error_class = + case response.code + when 400 then BadRequestError + when 401 then UnauthorizedError + when 402 then PaymentRequiredError + when 403 then ForbiddenError + when 404 then NotFoundError + when 405 then MethodNotAllowedError + when 406 then NotAcceptableError + when 407 then ProxyAuthenticationRequiredError + when 408 then RequestTimeoutError + when 409 then ConflictError + when 410 then GoneError + when 411 then LengthRequiredError + when 412 then PreconditionFailedError + when 413 then ContentTooLargeError + when 414 then UriTooLongError + when 415 then UnsupportedMediaTypeError + when 416 then RangeNotSatisfiableError + when 417 then ExpectationFailedError + when 418 then ImATeapotError + when 421 then MisdirectedRequestError + when 422 then UnprocessableContentError + when 423 then LockedError + when 424 then FailedDependencyError + when 425 then TooEarlyError + when 426 then UpgradeRequiredError + when 428 then PreconditionRequiredError + when 429 then TooManyRequestsError + when 431 then RequestHeaderFieldsTooLargeError + when 451 then UnavailableForLegalReasonsError + when 400...500 then ClientError # Generic client error if the 4xx code is unmapped. + when 500 then InternalServerError + when 501 then NotImplementedError + when 502 then BadGatewayError + when 503 then ServiceUnavailableError + when 504 then GatewayTimeoutError + when 505 then HttpVersionNotSupportedError + when 506 then VariantAlsoNegotiatesError + when 507 then InsufficientStorageError + when 508 then LoopDetectedError + when 510 then NotExtendedError + when 511 then NetworkAuthenticationRequiredError + when 500...600 then ServerError # Generic server error if the 5xx code is unmapped. + else StatusError + end + + raise error_class, response end HTTP::Options.register_feature(:raise_error, self) From 4245113e29cb8bd4fc55d3fc98583c7c165600ce Mon Sep 17 00:00:00 2001 From: Omkar Moghe Date: Tue, 5 May 2026 00:36:34 -0700 Subject: [PATCH 2/4] tests --- test/http/features/raise_error_test.rb | 28 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/test/http/features/raise_error_test.rb b/test/http/features/raise_error_test.rb index c14dda65..52767a01 100644 --- a/test/http/features/raise_error_test.rb +++ b/test/http/features/raise_error_test.rb @@ -37,26 +37,32 @@ def test_wrap_response_when_status_is_399_returns_original_response assert_same response, result end - def test_wrap_response_when_status_is_400_raises + def test_wrap_response_when_status_is_400_raises_bad_request_error feature = HTTP::Features::RaiseError.new(ignore: []) response = build_response(status: 400) - err = assert_raises(HTTP::StatusError) { feature.wrap_response(response) } + err = assert_raises(HTTP::BadRequestError) { feature.wrap_response(response) } assert_equal "Unexpected status code 400", err.message end - def test_wrap_response_when_status_is_599_raises + def test_wrap_response_when_status_is_500_raises_internal_server_error feature = HTTP::Features::RaiseError.new(ignore: []) - response = build_response(status: 599) - err = assert_raises(HTTP::StatusError) { feature.wrap_response(response) } - assert_equal "Unexpected status code 599", err.message + response = build_response(status: 500) + err = assert_raises(HTTP::InternalServerError) { feature.wrap_response(response) } + assert_equal "Unexpected status code 500", err.message end - def test_wrap_response_when_error_status_is_ignored_returns_original_response - feature = HTTP::Features::RaiseError.new(ignore: [500]) - response = build_response(status: 500) - result = feature.wrap_response(response) + def test_wrap_response_when_unmapped_4xx_status_raises_client_error + feature = HTTP::Features::RaiseError.new(ignore: []) + response = build_response(status: 499) + err = assert_raises(HTTP::ClientError) { feature.wrap_response(response) } + assert_equal "Unexpected status code 499", err.message + end - assert_same response, result + def test_wrap_response_when_unmapped_5xx_status_raises_server_error + feature = HTTP::Features::RaiseError.new(ignore: []) + response = build_response(status: 599) + err = assert_raises(HTTP::ServerError) { feature.wrap_response(response) } + assert_equal "Unexpected status code 599", err.message end # -- #initialize -- From c46d55e96c43eaeaebbb8ede6a9864094b21c9a9 Mon Sep 17 00:00:00 2001 From: Omkar Moghe Date: Sat, 16 May 2026 20:05:29 -0700 Subject: [PATCH 3/4] fix rubocop --- lib/http/features/raise_error.rb | 91 +++++++++++++++++--------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/lib/http/features/raise_error.rb b/lib/http/features/raise_error.rb index 01886ce3..52588aef 100644 --- a/lib/http/features/raise_error.rb +++ b/lib/http/features/raise_error.rb @@ -4,6 +4,49 @@ module HTTP module Features # Raises an error for non-successful HTTP responses class RaiseError < Feature + CODE_TO_ERROR_CLASS = { + 400 => BadRequestError, + 401 => UnauthorizedError, + 402 => PaymentRequiredError, + 403 => ForbiddenError, + 404 => NotFoundError, + 405 => MethodNotAllowedError, + 406 => NotAcceptableError, + 407 => ProxyAuthenticationRequiredError, + 408 => RequestTimeoutError, + 409 => ConflictError, + 410 => GoneError, + 411 => LengthRequiredError, + 412 => PreconditionFailedError, + 413 => ContentTooLargeError, + 414 => UriTooLongError, + 415 => UnsupportedMediaTypeError, + 416 => RangeNotSatisfiableError, + 417 => ExpectationFailedError, + 418 => ImATeapotError, + 421 => MisdirectedRequestError, + 422 => UnprocessableContentError, + 423 => LockedError, + 424 => FailedDependencyError, + 425 => TooEarlyError, + 426 => UpgradeRequiredError, + 428 => PreconditionRequiredError, + 429 => TooManyRequestsError, + 431 => RequestHeaderFieldsTooLargeError, + 451 => UnavailableForLegalReasonsError, + 500 => InternalServerError, + 501 => NotImplementedError, + 502 => BadGatewayError, + 503 => ServiceUnavailableError, + 504 => GatewayTimeoutError, + 505 => HttpVersionNotSupportedError, + 506 => VariantAlsoNegotiatesError, + 507 => InsufficientStorageError, + 508 => LoopDetectedError, + 510 => NotExtendedError, + 511 => NetworkAuthenticationRequiredError + }.freeze + # Initializes the RaiseError feature # # @example @@ -28,54 +71,14 @@ def wrap_response(response) return response if response.code < 400 return response if @ignore.include?(response.code) - error_class = + default_error_class = case response.code - when 400 then BadRequestError - when 401 then UnauthorizedError - when 402 then PaymentRequiredError - when 403 then ForbiddenError - when 404 then NotFoundError - when 405 then MethodNotAllowedError - when 406 then NotAcceptableError - when 407 then ProxyAuthenticationRequiredError - when 408 then RequestTimeoutError - when 409 then ConflictError - when 410 then GoneError - when 411 then LengthRequiredError - when 412 then PreconditionFailedError - when 413 then ContentTooLargeError - when 414 then UriTooLongError - when 415 then UnsupportedMediaTypeError - when 416 then RangeNotSatisfiableError - when 417 then ExpectationFailedError - when 418 then ImATeapotError - when 421 then MisdirectedRequestError - when 422 then UnprocessableContentError - when 423 then LockedError - when 424 then FailedDependencyError - when 425 then TooEarlyError - when 426 then UpgradeRequiredError - when 428 then PreconditionRequiredError - when 429 then TooManyRequestsError - when 431 then RequestHeaderFieldsTooLargeError - when 451 then UnavailableForLegalReasonsError - when 400...500 then ClientError # Generic client error if the 4xx code is unmapped. - when 500 then InternalServerError - when 501 then NotImplementedError - when 502 then BadGatewayError - when 503 then ServiceUnavailableError - when 504 then GatewayTimeoutError - when 505 then HttpVersionNotSupportedError - when 506 then VariantAlsoNegotiatesError - when 507 then InsufficientStorageError - when 508 then LoopDetectedError - when 510 then NotExtendedError - when 511 then NetworkAuthenticationRequiredError - when 500...600 then ServerError # Generic server error if the 5xx code is unmapped. + when 400...500 then ClientError + when 500...600 then ServerError else StatusError end - raise error_class, response + raise CODE_TO_ERROR_CLASS.fetch(response.code, default_error_class), response end HTTP::Options.register_feature(:raise_error, self) From 00831be4111695b7093b1f22047505ea06e83788 Mon Sep 17 00:00:00 2001 From: Omkar Moghe Date: Sat, 16 May 2026 20:23:22 -0700 Subject: [PATCH 4/4] test all mapped codes --- test/http/features/raise_error_test.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/http/features/raise_error_test.rb b/test/http/features/raise_error_test.rb index 52767a01..83ff27db 100644 --- a/test/http/features/raise_error_test.rb +++ b/test/http/features/raise_error_test.rb @@ -65,6 +65,15 @@ def test_wrap_response_when_unmapped_5xx_status_raises_server_error assert_equal "Unexpected status code 599", err.message end + def test_each_error_code + HTTP::Features::RaiseError::CODE_TO_ERROR_CLASS.each do |status, expected_error_class| + feature = HTTP::Features::RaiseError.new(ignore: []) + response = build_response(status: status) + err = assert_raises(expected_error_class) { feature.wrap_response(response) } + refute_nil err.message + end + end + # -- #initialize -- def test_initialize_defaults_ignore_to_empty_array