How should a Ruby shipping-rates gem structure its error classes so callers can rescue all of its errors or just one kind?
answer
- one namespaced base class
- base inherits from StandardError
- subclasses by what callers do
- fields over message parsing
- wrap inside rescue to keep cause
basics
~20 sDefine ShippingRates::Error < StandardError as the single base, then subclasses grouped by how callers react, such as ConfigurationError, CarrierError and RateLimited with a retry_after field. Raise wrappers inside rescue so the network error stays as cause.
solid answer
~40 sGive the gem one namespaced base, `ShippingRates::Error < StandardError`, and make every error the gem raises descend from it, so `rescue ShippingRates::Error` is a complete, safe catch-all for the library. Below it, split by **what a caller would do differently**: `ConfigurationError` for a missing API key (fix and redeploy), `CarrierError` with a `carrier` field for upstream failures, `RateLimited < CarrierError` with `retry_after`, `CarrierTimeout < CarrierError` for network timeouts. Let third-party errors such as `Net::ReadTimeout` or `JSON::ParserError` not leak: rescue them at the boundary and raise the gem's class inside the rescue clause so Ruby records the original as `cause`. Keep invalid-argument errors from the caller's own mistakes as plain `ArgumentError`, or as a gem subclass if callers need one catch-all for everything.
code
ruby · 20 linesmodule ShippingRates
Retryable = Module.new
class Error < StandardError; end
class ConfigurationError < Error; end
class InvalidAddress < Error; end
class CarrierError < Error
attr_reader :carrier
def initialize(msg = "carrier request failed", carrier: nil)
super(msg)
@carrier = carrier
end
end
class CarrierTimeout < CarrierError
include Retryable
end
endgo deeper
Know the convention: a namespaced Error class inheriting from StandardError that all of a gem's errors descend from.
Explain subclasses grouped by caller reaction, intermediate parents, and fields instead of message parsing.
Wrap third-party errors at the boundary inside rescue to keep cause, use marker modules for traits like retryability, and treat the tree as public API.
Own the compatibility policy: which error changes are breaking, how caller-bug errors are classified, and how the tree evolves across major versions.
## Why a gem needs its own tree A library that raises a mix of `RuntimeError`, `Net::ReadTimeout`, `JSON::ParserError` and `KeyError` forces every caller to know its internals. When the library switches HTTP clients or JSON parsers, callers' rescue clauses silently stop matching. A **dedicated error tree** makes the library's failure modes part of its public API, versioned with it. ## The base class The convention across Ruby gems is a single base class inside the gem's namespace: - **`ShippingRates::Error < StandardError`**. Inheriting from `StandardError` means callers' ordinary `rescue => e` still catches it, and RuboCop's `Lint/InheritException` would flag `Exception` as the parent anyway. - **Everything the gem raises descends from it**, so `rescue ShippingRates::Error` is a guaranteed catch-all for this library and nothing else. - Name it `Error` inside the namespace; the full name `ShippingRates::Error` is unambiguous in callers' code. ## Subclasses by caller reaction Split below the base according to **what a caller would do differently**, not by where in the code the error arose: | Class | Parent | Caller reaction | Fields | |---|---|---|---| | `ConfigurationError` | `Error` | fix configuration; do not retry | `setting` | | `CarrierError` | `Error` | show a fallback price or try another carrier | `carrier`, `status` | | `RateLimited` | `CarrierError` | wait, then retry | `retry_after` | | `CarrierTimeout` | `CarrierError` | retry with backoff | `carrier` | | `InvalidAddress` | `Error` | show a validation message to the user | `field` | Intermediate classes like `CarrierError` let callers choose their granularity: rescue `RateLimited` precisely, `CarrierError` for any upstream problem, or `Error` for everything. ## Wrapping third-party errors At the boundary where the gem calls the network or a parser: 1. Rescue the specific low-level classes, such as `Net::OpenTimeout`, `Net::ReadTimeout` or `JSON::ParserError`. 2. Raise the gem's class **inside that rescue clause**. `raise` defaults its `cause:` keyword to `$!`, so the original error is attached as `cause` with its backtrace. 3. Copy the facts callers need into fields (`carrier:`, `status:`), rather than into the message only. Callers then never rescue `Net::ReadTimeout` directly, and upgrading the HTTP client does not break them. ## Callers' own mistakes Passing `weight: -1` is a bug in the caller, not an operational failure. Two conventions exist: - raise plain **`ArgumentError`** for programmer errors, and reserve the gem tree for runtime conditions; or - define **`ShippingRates::ArgumentError < ShippingRates::Error`** when callers want one clause that catches everything from the gem. Whichever you pick, document it, because it decides whether `rescue ShippingRates::Error` also hides caller bugs. ## Marker modules for cross-cutting traits Ruby classes have one superclass, so a trait such as "safe to retry" cannot be a second parent. A **module** can mark it: `module ShippingRates::Retryable; end`, included in `RateLimited` and `CarrierTimeout`. Because a rescue clause matches with `===`, `rescue ShippingRates::Retryable` catches every error that includes the module, whatever its place in the tree. ## Keeping the tree stable - Treat removing or re-parenting a class as a **breaking change**, since callers' rescue clauses depend on it. - Add new subclasses under an existing parent so old rescue clauses keep catching them. - Keep fields small and serialisable, so errors can be logged or sent to a tracker without dragging large objects along. ## Documenting the tree List every class in the gem's README with one line on when it is raised and which fields it carries, and mention which parent to rescue for common needs: `Error` for everything, `CarrierError` for upstream trouble, `Retryable` for automatic retries. Callers read that list far more often than the source, and it doubles as the compatibility contract for the next release.
- Why must the base error inherit from StandardError rather than Exception?Callers write `rescue => e` and bare `rescue` expecting to catch library failures. Those clauses cover only `StandardError`. A base inheriting from `Exception` would escape them and crash callers' request handlers or jobs, and RuboCop's `Lint/InheritException` reports it.
- Why add intermediate classes such as CarrierError between the base and the leaves?They let callers pick their granularity: `RateLimited` precisely, `CarrierError` for any upstream trouble, `Error` for everything. They also make the tree extensible, because a new leaf added under `CarrierError` in a later release is caught by callers' existing `rescue ShippingRates::CarrierError` clauses without any change.
saying these in an interview costs you the question
- Each error in a gem should inherit directly from StandardError
- Letting Net::ReadTimeout escape the gem is fine for callers
- The gem's base error should inherit from Exception to be safe
- Callers should match error messages to tell failures apart
- Moving an error class to a new parent is a safe refactor