problem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 476a7c373272d8029f0162849ab784ee875af959cbb230b96ebe35d96713fa06
4
+ data.tar.gz: 17659bc6344e26a107943c95c4d37e3d05b282a4020ca22190ca60d50b89562f
5
+ SHA512:
6
+ metadata.gz: 82e739ccf9de64bf38e046c84288e34ad9d252f2fd1ebed7d00ed5bdf36658efb7363b778b23644ab69748518fdd305948e9c18faed198464abbb1714914a9c5
7
+ data.tar.gz: 139c2cd5274e5c57a4b070265a40355455f987622f6df583b7069c8957125240dce027e1d41bff1e5493b502a34d37ee442adbe5f7375f20ee8a3b76e8890e16
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-22
4
+
5
+ Initial release.
6
+
7
+ - `Problem::Detailable` declares an exception class as an RFC 9457 problem, with a
8
+ class-level `type` / `status` / `title` DSL and a per-occurrence `detail`.
9
+ - `Problem::Rescuable` renders those as `application/problem+json`, with overridable
10
+ steps for building, reporting and rendering.
11
+ - `Problem::ExceptionsApp` answers exceptions that escape the controller.
12
+ - `Problem::I18nable` looks titles up through I18n.
13
+ - `Problem::RetryAfter` publishes a retry interval as a header and an extension member.
data/DESIGN.md ADDED
@@ -0,0 +1,274 @@
1
+ # Design
2
+
3
+ Why this library is shaped the way it is. [README.md](README.md) covers using it.
4
+
5
+ Extracted from a production API, where most of these decisions were paid for once
6
+ already.
7
+
8
+ ## The declaration belongs on the error class
9
+
10
+ The obvious alternative is a central table in `ApplicationController` pairing exception
11
+ classes with statuses. It was rejected for three reasons.
12
+
13
+ It drifts. The table sits far from the code that raises, so an error added in one place
14
+ and registered in another eventually stops being registered at all.
15
+
16
+ It makes adding an error a two-file change, which is the kind of friction that produces
17
+ bare `head :forbidden` calls instead.
18
+
19
+ It has no answer for a subclass that must render exactly as its parent. When telling two
20
+ failures apart would leak something (which factor of an authentication attempt failed,
21
+ whether a record exists at all), the response has to be byte-identical. With the
22
+ declaration inherited, an empty subclass is the entire implementation. With a table, it
23
+ is a second entry that has to be kept in sync by hand.
24
+
25
+ `class_attribute` is what makes a declaration both inherited and overridable. A constant
26
+ could not be overridden; an ivar on the singleton class would be invisible to subclasses.
27
+
28
+ ## Two halves, because the controller is not always there
29
+
30
+ `Problem::Rescuable` is a controller concern. A `rescue_from` handler still has the
31
+ controller, so it can reach `request`, the negotiated locale, and whatever a
32
+ `before_action` computed. Middleware sees a Rack env and can do none of that.
33
+
34
+ But an exception raised before dispatch never reaches a controller at all. A routing
35
+ error, an unreadable body, a failure in another middleware: no `rescue_from` will ever
36
+ run. So `Problem::ExceptionsApp` exists as a separate object, and the two are wired
37
+ independently.
38
+
39
+ `ExceptionsApp` wraps another exceptions app rather than subclassing
40
+ `ActionDispatch::PublicExceptions`. A wrapper lets each layer of a stack claim the
41
+ requests it recognizes and pass on the rest, which is what makes it composable with, say,
42
+ a Connect RPC exceptions app in front of it:
43
+
44
+ ```ruby
45
+ config.exceptions_app = ConnectExceptions.new(
46
+ Problem::ExceptionsApp.new(ActionDispatch::PublicExceptions.new(Rails.public_path)),
47
+ )
48
+ ```
49
+
50
+ A subclass can only ever be the innermost layer, and forces whoever wants the JSON branch
51
+ to reimplement the HTML branch they did not want to touch.
52
+
53
+ Its titles are the status text, never the exception message. A message can quote an id, a
54
+ column name or a path, and this is the one place with no controller to have decided what
55
+ a caller may see.
56
+
57
+ ## The value object
58
+
59
+ `Problem::Details` is a frozen `Data` with no Rails in it, so building and serializing a
60
+ document needs no controller and no boot.
61
+
62
+ Extension members merge at the top level because RFC 9457 §3.2 defines them as members of
63
+ the problem object, not a nested container. One that shadowed `status` would contradict
64
+ the HTTP status in the same response, so a collision raises when the value object is
65
+ built rather than at render time inside an error path.
66
+
67
+ `to_json` is defined explicitly. The generic `Object#to_json` serializes a `Data` as its
68
+ inspect output, which is a plausible-looking response body that no assertion on the
69
+ status code would catch.
70
+
71
+ Serialization lives in `Problem::Document`, a module written against readers. A `Data`
72
+ cannot gain members by subclassing, so a deployment that wants a *typed* extension member
73
+ defines its own `Data` over the member list:
74
+
75
+ ```ruby
76
+ TracedProblem = Data.define(*Problem::Details.members, :trace_id) do
77
+ include Problem::Document
78
+
79
+ def to_h = super.merge(trace_id:)
80
+ end
81
+ ```
82
+
83
+ Nothing requires `#to_problem` to return a `Problem::Details`. `Problem::Rescuable` calls
84
+ `#status`; the renderer calls `#status` and `#to_json`. That is also how a deployment
85
+ keeps rendering through a serializer it already has.
86
+
87
+ `Details` is written as a `Data.define` assignment reopened as a class, rather than
88
+ `class Details < Data.define(...)`. Both rubocop and Steep prefer it: the first flags the
89
+ inheritance form, and the second cannot see into a `Data.define` block.
90
+
91
+ ## Type URIs
92
+
93
+ Resolution happens in `Detailable#to_problem`, not in `Details.new`. That keeps the value
94
+ object pure, so a document built by hand is never rewritten behind the caller's back.
95
+
96
+ | declared `type` | result |
97
+ |---|---|
98
+ | `nil` | serialized as `about:blank` |
99
+ | `"about:blank"` | returned untouched |
100
+ | carries a scheme | returned untouched, it is already absolute |
101
+ | anything else | prefixed, or left alone when no prefix is set |
102
+
103
+ `about:blank` is special-cased because prefixing it produces a URI that looks valid and
104
+ identifies nothing, and it is the one `type` value the RFC assigns a meaning to.
105
+
106
+ Leaving a slug unprefixed is legal: RFC 9457 defines `type` as a URI *reference*, not a
107
+ URI. The prefix exists so sixty error classes do not each repeat an authority, which is
108
+ sixty chances to typo it.
109
+
110
+ ## Plugging in a problem catalogue
111
+
112
+ A deployment that keeps its problems in a registry (an enum, a YAML file, a table) wants
113
+ type, status and title derived from it. `Problem::I18nable` is the shipped example of the
114
+ simple case, overriding `title` in a `ClassMethods` that sits ahead of the DSL. When the
115
+ catalogue layer is its own concern, prepend a module to the error class's singleton and
116
+ call `super` for anything it does not cover:
117
+
118
+ ```ruby
119
+ module Typeable
120
+ extend ActiveSupport::Concern
121
+
122
+ module Derivation
123
+ def type(value = nil)
124
+ return super if value || problem_type.nil?
125
+
126
+ CATALOGUE.fetch(problem_type).fetch(:type)
127
+ end
128
+ end
129
+
130
+ included { singleton_class.prepend(Derivation) }
131
+ end
132
+ ```
133
+
134
+ A prepend rather than a pluggable resolver object. A resolver would bless one catalogue
135
+ shape, add global mutable state, and not compose with inheritance. `prepend` and `super`
136
+ compose for free, and a class that wants a literal declaration simply makes one.
137
+
138
+ Five things make this work, none of them visible at a call site. They are the real public
139
+ contract of `Problem::Detailable`, and each fails silently rather than loudly:
140
+
141
+ 1. `ClassMethods` is attached with `extend`, so a singleton prepend sits ahead of it.
142
+ 2. The signatures stay `status(value = nil)`, `type(value = nil)` and
143
+ `title(value = nil, interpolations: {})`. The keyword *name* `interpolations:` is
144
+ load-bearing: a derivation calls `super` with it, and renaming it raises only while
145
+ rendering an error, in production, on a response that was already an error.
146
+ 3. Those three are never defined on the including class itself, only in `ClassMethods`,
147
+ or the prepend stops winning.
148
+ 4. `#to_problem` reaches them through `self.class` rather than reading the class
149
+ attributes behind them. Reading `problem_uri` directly is a one-word change that
150
+ disables the whole derivation layer with no error anywhere.
151
+ 5. The catalogue layer is included *before* `Problem::Detailable`. Included after, its
152
+ fallbacks shadow the real DSL and every literal declaration reads back `nil`.
153
+
154
+ And one more, which is the trap people actually hit: extending a `ClassMethods` onto a
155
+ subclass puts it *ahead* of the prepend its superclass holds. A concern that mixes in a
156
+ catalogue layer has to re-apply the prepend itself, on every inclusion.
157
+
158
+ `spec/problem/detailable_derivation_spec.rb` builds the whole arrangement and pins it,
159
+ including both silent failure modes. Without that spec the extraction is one refactor
160
+ away from breaking quietly.
161
+
162
+ ### Why I18nable needs none of that ceremony
163
+
164
+ `Problem::I18nable` overrides the same method and is a plain `ActiveSupport::Concern`,
165
+ with no prepend at all. It gets away with it by declaring `include Detailable` as a
166
+ concern dependency: `ActiveSupport::Concern` then includes `Detailable` first and extends
167
+ `I18nable::ClassMethods` afterwards, which puts it ahead in the singleton ancestry and
168
+ leaves `super` pointing at the literal DSL. Re-including `Detailable` explicitly, in
169
+ either order, changes nothing, because a module already in the ancestry is not moved.
170
+
171
+ The prepend is only needed when the layer supplies terminal fallbacks of its own, as a
172
+ catalogue concern does. `I18nable` supplies none; it defers to `super`.
173
+
174
+ `i18n` is not a declared dependency of the gem, so `Problem::I18nable` is autoloaded
175
+ rather than required. A host that never references the constant never loads `i18n`
176
+ through it.
177
+
178
+ ## Rendering
179
+
180
+ One `rescue_from`, registered against `Problem::Detailable` itself. A Module is matched
181
+ with `===`, so a single registration covers every error class that mixed it in.
182
+
183
+ The handler is split into named steps so a deployment overrides the one it needs, and so
184
+ a different transport replaces only `#render_problem`. A Connect RPC controller carries
185
+ the document as an error detail and answers in the protocol's own shape; nothing else
186
+ about the handler changes.
187
+
188
+ ### Localization
189
+
190
+ The gem never calls `I18n`. `around_problem_render` is the seam, and it wraps the whole
191
+ handler rather than just the render for a reason worth writing down:
192
+ `ActionController::Rescue` wraps `AbstractController::Callbacks`, so an `around_action`
193
+ that established the locale has **already unwound** by the time a `rescue_from` handler
194
+ runs. The title lookup happens while the problem is being built, not while it renders, so
195
+ wrapping only the render would miss it.
196
+
197
+ ### Reporting
198
+
199
+ `rescue_from` swallows the exception. Nothing logs it, and no error reporter's automatic
200
+ capture sees it, which is a quiet way to lose every 5xx an API returns.
201
+
202
+ Reports go through `ActiveSupport.error_reporter`, the registry Rails error reporters
203
+ subscribe to, rather than naming a vendor. 4xx are not reported: an expected client error
204
+ is not an incident.
205
+
206
+ ## Initialization
207
+
208
+ A Railtie, because that is what Rails offers for exactly this, instead of an initializer
209
+ every host copies. The same work is a plain `Problem.install!` for a Rack host or a spec
210
+ that never boots Rails, which is how this gem's own suite reaches it.
211
+
212
+ `problem.config` declares `after: :load_config_initializers`. A railtie initializer
213
+ otherwise runs before `config/initializers` is loaded, which would make
214
+ `config.problem.type_prefix` silently do nothing when set in the file an application
215
+ would most expect to set it in.
216
+
217
+ The renderer takes the status from the object it is handed and calls `to_json` on it,
218
+ requiring nothing more. That is what lets a deployment render through its own serializer.
219
+
220
+ The Railtie deliberately does not touch `config.exceptions_app`. Replacing what an
221
+ application set there is not an initializer's business.
222
+
223
+ ## Types
224
+
225
+ Signatures are inline `#:` annotations generated into `sig/generated`, which is checked
226
+ in. CI asserts it is current with `git diff --exit-code`, because an annotation can change
227
+ without anyone running `rake rbs`, and a stale signature means the type check is checking
228
+ a shape the code no longer has.
229
+
230
+ `sig/manual` holds what inline annotations cannot express:
231
+
232
+ * the self-type of a mix-in, since `Problem::Rescuable` needs `render` and `response` from
233
+ whatever controller it lands in, and `Problem::Detailable` needs its own class-level DSL;
234
+ * the accessors `class_attribute` installs at runtime;
235
+ * `Problem::Details`, since a `Data.define` assignment has no inferable shape;
236
+ * a `Data#initialize` stub, because core RBS declares none and a subclass overriding it
237
+ has nothing to call `super` on.
238
+
239
+ Three DSL blocks are marked `steep:ignore`: `included do`, and the renderer registration.
240
+ Their `self` is supplied by Rails at runtime and cannot be named in RBS. `class_methods do`
241
+ was avoided entirely in favour of a nested `ClassMethods` module, which is ordinary code
242
+ that Steep can check and which `ActiveSupport::Concern` picks up by name.
243
+
244
+ ## Layout
245
+
246
+ ```
247
+ lib/problem/document.rb to_h / as_json / to_json, and the media type
248
+ lib/problem/details.rb the value object
249
+ lib/problem/detailable.rb the class-level DSL, and #to_problem
250
+ lib/problem/i18nable.rb titles from I18n, autoloaded
251
+ lib/problem/retry_after.rb Retry-After, as a worked example of the hooks
252
+ lib/problem/rescuable.rb rescue_from, split into overridable steps
253
+ lib/problem/exceptions_app.rb the config.exceptions_app wrapper
254
+ lib/problem/renderer.rb the application/problem+json renderer
255
+ lib/problem/railtie.rb required only when Rails is present
256
+ sig/manual/ what RBS cannot infer
257
+ ```
258
+
259
+ ## Deliberately out of scope
260
+
261
+ * A catalogue of problem types, and anything that reads one. The seam is above.
262
+ * `Accept-Language` negotiation. `Problem::I18nable` translates a title once a locale is
263
+ set; choosing that locale is the application's job, and `around_problem_render` is
264
+ where it plugs in.
265
+ * Validation-error serialization. RFC 9457 defines no `errors` member; use an extension.
266
+ * `application/problem+xml`.
267
+ * Anything Connect or gRPC. `render_problem` is the seam, and
268
+ [connect_rpc_rails](https://github.com/ivry-inc/connect_rpc_rails) is the other half.
269
+
270
+ ## A note on the name
271
+
272
+ The top-level constant is `Problem`, generic enough to collide with a `Problem` model in
273
+ a host application. Nothing here reopens or autoloads anything outside `Problem::`, but
274
+ under Zeitwerk a same-named application constant is a conflict you would have to resolve.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sorah Fukumori
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # problem: RFC 9457 Problem Details for Rails APIs
2
+
3
+ `problem` renders the errors a Rails API raises as
4
+ [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem details. An error class
5
+ declares its type, title and status once, next to the code that raises it, and a
6
+ controller concern turns any of them into an `application/problem+json` response. A
7
+ companion exceptions app covers what Rails raises before your controller runs, so a
8
+ routing error and a business rule violation come back in the same shape.
9
+
10
+ ```ruby
11
+ class Errors::Forbidden < Errors::ApiError
12
+ type "forbidden"
13
+ status 403
14
+ title "Forbidden"
15
+ end
16
+
17
+ raise Errors::Forbidden.new(detail: "Only the owner can cancel this order")
18
+ ```
19
+
20
+ ```console
21
+ $ curl -i https://api.example.com/orders/1/cancel
22
+ HTTP/1.1 403 Forbidden
23
+ Content-Type: application/problem+json
24
+
25
+ {"type":"forbidden","title":"Forbidden","status":403,"detail":"Only the owner can cancel this order"}
26
+ ```
27
+
28
+ ## Features
29
+
30
+ - **One place per error.** Status, title and type live on the class, not in a mapping
31
+ table that drifts away from the code raising it.
32
+ - **Stable identifiers for clients.** Callers dispatch on `type`, so you can reword a
33
+ title without breaking them.
34
+ - **Covers what the controller never sees.** Routing errors, unreadable bodies and
35
+ middleware failures render as problem documents too, carrying the status text rather
36
+ than the exception message.
37
+ - **Localized titles**, keyed by type, with a one-line include.
38
+ - **Small.** Two mixins, an exceptions app and a renderer. Under 250 lines of code, and
39
+ `actionpack` is the only dependency.
40
+ - **Typed.** RBS signatures ship with the gem.
41
+
42
+ ## Requirements
43
+
44
+ Ruby 3.3 or later, and `actionpack` 7.0 or later. `Problem::I18nable` additionally needs
45
+ the `i18n` gem, which Rails already brings.
46
+
47
+ ## Installation
48
+
49
+ ```
50
+ bundle add problem
51
+ ```
52
+
53
+ Rails wires itself up through a railtie. Add an initializer for the URI prefix your type
54
+ identifiers live under:
55
+
56
+ ```ruby
57
+ # config/initializers/problem.rb
58
+ Rails.application.configure do
59
+ config.problem.type_prefix = "https://api-probs.example.com/"
60
+ end
61
+ ```
62
+
63
+ `config/application.rb` and the environment files work too.
64
+
65
+ Outside Rails, call `Problem.install!` at boot and configure it with
66
+ `Problem.configure { |c| c.type_prefix = "..." }`.
67
+
68
+ ## Getting started
69
+
70
+ ### 1. Give your errors a common base
71
+
72
+ ```ruby
73
+ # app/models/errors.rb
74
+ module Errors
75
+ class ApiError < StandardError
76
+ include Problem::Detailable
77
+ end
78
+
79
+ class BadRequest < ApiError
80
+ type "bad-request"
81
+ status 400
82
+ title "Bad Request"
83
+ end
84
+
85
+ class Unauthorized < ApiError
86
+ type "unauthorized"
87
+ status 401
88
+ title "Unauthorized"
89
+ end
90
+
91
+ class Forbidden < ApiError
92
+ type "forbidden"
93
+ status 403
94
+ title "Forbidden"
95
+ end
96
+
97
+ class NotFound < ApiError
98
+ type "not-found"
99
+ status 404
100
+ title "Not Found"
101
+ end
102
+ end
103
+ ```
104
+
105
+ One base class carrying the concern is enough. Everything under it inherits the
106
+ declaration and overrides only what differs.
107
+
108
+ ### 2. Rescue them once
109
+
110
+ ```ruby
111
+ # app/controllers/application_controller.rb
112
+ class ApplicationController < ActionController::API
113
+ include Problem::Rescuable
114
+ end
115
+ ```
116
+
117
+ ### 3. Raise them
118
+
119
+ ```ruby
120
+ raise Errors::NotFound unless @order
121
+
122
+ raise Errors::Forbidden.new(detail: "Only the owner can cancel this order")
123
+ ```
124
+
125
+ `detail` is the part that differs between two occurrences of the same problem. Everything
126
+ else belongs to the class, so it is declared once.
127
+
128
+ ## Defining your own errors
129
+
130
+ Inherit from the closest base and declare what changes:
131
+
132
+ ```ruby
133
+ class Orders::AlreadyShipped < Errors::UnprocessableContent
134
+ type "order-already-shipped"
135
+ title "Order Already Shipped"
136
+ end
137
+ ```
138
+
139
+ A subclass that declares nothing renders exactly as its parent:
140
+
141
+ ```ruby
142
+ class Errors::ConfidentialClientRequired < Errors::Unauthorized; end
143
+ ```
144
+
145
+ That is worth knowing deliberately. When a caller must not be able to tell two failures
146
+ apart, an empty subclass is the whole implementation.
147
+
148
+ ## Localized titles
149
+
150
+ `title` is the only member written for a human, so it is the only one worth translating.
151
+ Include `Problem::I18nable` in your base class:
152
+
153
+ ```ruby
154
+ module Errors
155
+ class ApiError < StandardError
156
+ include Problem::I18nable
157
+ end
158
+ end
159
+ ```
160
+
161
+ ```yaml
162
+ # config/locales/en.yml
163
+ en:
164
+ problem_details:
165
+ titles:
166
+ not_found: "Not Found"
167
+ forbidden: "Forbidden"
168
+ ```
169
+
170
+ Titles are keyed by the declared type with dashes replaced, under
171
+ `problem_details.titles`. Both are adjustable:
172
+
173
+ ```ruby
174
+ class Errors::ApiError < StandardError
175
+ include Problem::I18nable
176
+
177
+ title_scope "errors.titles" # inherited by subclasses
178
+ end
179
+
180
+ class Errors::NotFound < Errors::ApiError
181
+ type "gone-missing"
182
+ status 404
183
+ title_key :not_found # when the key should not follow the type
184
+ end
185
+ ```
186
+
187
+ `Problem::I18nable` brings `Problem::Detailable` with it, so one include covers both. A
188
+ class that spells out a literal `title` keeps it, which lets a codebase move over
189
+ gradually.
190
+
191
+ Then establish the locale around rendering:
192
+
193
+ ```ruby
194
+ class ApplicationController < ActionController::API
195
+ include Problem::Rescuable
196
+
197
+ private def around_problem_render(&) = I18n.with_locale(negotiated_locale, &)
198
+ end
199
+ ```
200
+
201
+ That wrapper is not optional if you localize. Rails has already unwound your
202
+ `around_action` by the time an error renders, so the locale has to be re-established
203
+ here. [DESIGN.md](DESIGN.md#localization) explains why.
204
+
205
+ ## Catching what escapes the controller
206
+
207
+ A routing error, an unreadable request body or a failure in middleware never reaches a
208
+ controller, so `Problem::Rescuable` never sees it. Wire up the exceptions app:
209
+
210
+ ```ruby
211
+ # config/initializers/problem.rb
212
+ Rails.application.configure do
213
+ config.exceptions_app = Problem::ExceptionsApp.new(
214
+ ActionDispatch::PublicExceptions.new(Rails.public_path),
215
+ )
216
+ end
217
+ ```
218
+
219
+ Browsers keep getting the static error pages. Everything else gets a problem document,
220
+ with the status Rails already mapped the exception to.
221
+
222
+ ## Error reporting
223
+
224
+ 5xx problems are reported through `ActiveSupport.error_reporter`, which Sentry and similar
225
+ gems subscribe to. 4xx are not, on the grounds that an expected client error is not an
226
+ incident. Both are overridable:
227
+
228
+ ```ruby
229
+ private def report_problem(error, _problem) = Sentry.capture_exception(error)
230
+
231
+ private def report_problem?(_error, problem) = problem.status >= 500
232
+ ```
233
+
234
+ ## Telling clients when to retry
235
+
236
+ ```ruby
237
+ class Errors::TooManyRequests < Errors::ApiError
238
+ include Problem::RetryAfter
239
+
240
+ type "too-many-requests"
241
+ status 429
242
+ title "Try again in %{retry_after} seconds"
243
+ end
244
+
245
+ raise Errors::TooManyRequests.new(retry_after: 30)
246
+ ```
247
+
248
+ Sends a `Retry-After` header, interpolates the wait into the title, and adds a
249
+ `retry_after` member to the body. A `Time` works in place of seconds.
250
+
251
+ ## Extension members
252
+
253
+ RFC 9457 lets a problem carry extra top-level members. Return them from the occurrence:
254
+
255
+ ```ruby
256
+ class Orders::PaymentDeclined < Errors::UnprocessableContent
257
+ type "payment-declined"
258
+ title "Payment Declined"
259
+
260
+ def problem_extensions = {decline_code: "insufficient_funds"}
261
+ end
262
+ ```
263
+
264
+ ```json
265
+ {"type":"payment-declined","title":"Payment Declined","status":422,"decline_code":"insufficient_funds"}
266
+ ```
267
+
268
+ To fill `instance`, which identifies the occurrence rather than the type, use the request:
269
+
270
+ ```ruby
271
+ private def problem_for(error) = error.to_problem.with(instance: request.fullpath)
272
+ ```
273
+
274
+ ## Reference
275
+
276
+ Members of the rendered document:
277
+
278
+ | member | set by | notes |
279
+ |---|---|---|
280
+ | `type` | `type "slug"` | Resolved against `type_prefix`. Defaults to `about:blank`. |
281
+ | `title` | `title "..."`, or `Problem::I18nable` | Short, human readable, constant for the type. |
282
+ | `status` | `status 403` | Always matches the HTTP status. |
283
+ | `detail` | `new(detail:)` | Specific to the occurrence. Omitted when absent. |
284
+ | `instance` | `problem_instance` | Omitted when absent. |
285
+ | anything else | `problem_extensions` | Serialized as top-level members. |
286
+
287
+ Hooks on a controller including `Problem::Rescuable`:
288
+
289
+ | hook | for |
290
+ |---|---|
291
+ | `problem_for(error)` | Adding `instance`, a request id, anything request-derived |
292
+ | `around_problem_render(&)` | Locale, or other per-request state the handler needs |
293
+ | `report_problem?(error, problem)` | What counts as reportable |
294
+ | `report_problem(error, problem)` | Where reports go |
295
+ | `render_problem(problem, error)` | Answering in a different shape entirely |
296
+
297
+ ## Caveats
298
+
299
+ - The top-level constant is `Problem`. Under Zeitwerk, an application with its own
300
+ `Problem` model has a conflict to resolve.
301
+ - `i18n` is not a declared dependency. `Problem::I18nable` is autoloaded, so a host that
302
+ never references it never loads it.
303
+ - RFC 9457 defines no member for field-level validation errors. Use an extension member.
304
+ - `application/problem+xml` is not implemented.
305
+
306
+ ## Development
307
+
308
+ ```
309
+ bundle install
310
+ bundle exec rake # specs, then rbs and steep
311
+ hk check --all # rubocop, actionlint, zizmor
312
+ hk install # run the linters on commit
313
+ ```
314
+
315
+ ## See also
316
+
317
+ - [DESIGN.md](DESIGN.md) for why the library is shaped this way, and how to derive
318
+ declarations from a problem catalogue of your own.
319
+ - [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), the format itself.
320
+
321
+ ## Contributing
322
+
323
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/problem.
324
+
325
+ ## License
326
+
327
+ The gem is available as open source under the terms of the
328
+ [MIT License](https://opensource.org/licenses/MIT). Copyright (c) 2026 Sorah Fukumori.