zappb-exceptions 1.0.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: 7c0be24c4d6adeab3ed623c2a0f6b6318506fbe85895fe0d992e5ade463cc09c
4
+ data.tar.gz: c015a50b34a83af5f391cc0ad948272dd09423635e71dff51bd30391f9c37bc0
5
+ SHA512:
6
+ metadata.gz: c5420706184acb30a4585ff6b7cf1a820da1ed336c54c4c2c781e6a08615320c37f194996e6973eb9b75657a044625246bdf204425d898eab9cde237963981cf
7
+ data.tar.gz: 51475fa8e66e36a7d30dd8dea75973960b0f54c85db770592054d6ffa029f99ab79610b3bb17a89394d43e6884faefbfa64cbe07bf178d31b161661b7549a41a
data/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.0.0] - 2026-11-09
6
+
7
+ ### Added
8
+
9
+ - Catalog: `Definition`, `Registry`, `ClassFactory`, `Resolver`, `Envelope`, `Notifier`, `Handler` (Rails), `Rack` and the base catalog of generic HTTP errors.
10
+ - `ZappbException::NoMethodError`, `NameError`, `ArgumentError` and `Exception` exist in the namespace and inherit from `BaseError`.
11
+ - A single `rescue_from StandardError` resolves by class hierarchy (independent of catalog order).
12
+ - NATS layer: `Nats.reply`, `Nats.error_response?`, `Nats.raise_on_error!(response, on_nil:, meta:)` and `RemoteError.from` (tolerant reading, `RemoteError::NoResponse`). `on_nil` accepts an exception class or instance.
13
+ - `RemoteError#success?` (strict: a Hash with `success` true or "true"; different from `ok?`) and `RemoteError#extra`. `NoResponse` and `ForwardedError` support them.
14
+ - `ForwardedError#remote`, `#status`, `#code`, `#kind?`, `#success?`, `#extra`, `#detail` and `#remote_meta`.
15
+ - Test helpers: `zappb_exception/testing` (`Testing.error_response(..., extra:, meta:)`, `success_response`) and `zappb_exception/rspec` (`be_zappb_error`, `raise_zappb_error`, `match_error_envelope(meta:)`). `Testing.error_response` reports the same `error.exception` as the real Envelope, including the adopted class name for `klass:` entries.
16
+ - The Handler registers a `Proc` instead of an instance method, so class-level `rescue_with_handler` works.
17
+ - `Rack.render` notifies through the Notifier by default (`notify: false` to disable).
18
+ - Extra data in `error`: `BaseError#extra_data`, `PayloadError`, `register(payload: true)`, merged in the Envelope without overwriting `exception`/`detail`.
19
+ - `register(payload: %i[retry_after])`: readers, `extra_data` with every declared key (nil by default), `ArgumentError` for undeclared keys, `ConfigurationError` for reserved keys.
20
+ - Namespaced internal names (`register "Billing::InvoiceError"`): the gem creates `ZappbException::Billing::InvoiceError` and the intermediate modules (nestable, any registration order). I18n key: the full name with `::`.
21
+ - Per-service meta: `config.meta_key`; `BaseError#meta` / `with_meta`; `meta:` keyword in `Handler.handle`, `Rack.render`, `Nats.reply`, `Nats.raise_on_error!` and `generate_json`; `PayloadError` and `ForwardedError` accept `meta:`. The Envelope signs local meta with `meta_key`, keeps the remote meta of a `ForwardedError` and merges both (local wins). Events include `meta`; `RemoteError#meta`.
22
+ - Base entry `MissingConfiguration` (500-90) and per-entry `register(safe_detail:)`, which overrides `config.safe_detail` (events still receive the full message).
23
+ - `Configuration#alias_external(class_name, to:)`: maps an external class to an existing entry without `override:`.
24
+ - `ZappbException.describe(exception)` returning `Description(status, code, message)`.
25
+ - `ZappbException.log_level(exception)` and `Description#level`: `:warn` (< 500) or `:error` (>= 500); the Notifier uses the same function.
26
+ - Events `zappb_exception.error` and `zappb_exception.forwarded_error` include `extra` (omitted when empty).
27
+ - Default safe detail in the base catalog: `safe_detail: true` in `RecordNotFound` (and its `ActiveRecord::RecordNotFound` alias), `Exception`, `NameError`, `NoMethodError`, `Wrong200`, `TokenError`, `ActionView::Template::Error`, `ServiceUnavailable` and `GatewayTimeout`; the `500-<P>00` fallback also hides the detail of non-`BaseError` exceptions (unless a global `config.safe_detail` of `true`/callable is set). `RecordInvalid` is not hidden.
28
+ - Reserved numbers: 90-99 of every status are reserved for future base entries.
29
+ - I18n: English (`en.yml`) and Spanish (`es.yml`) base messages and the new `config.locale` option (`nil` follows `I18n.locale`; a Symbol/String forces that locale for every message the gem produces). Locale files are added to `I18n.load_path` with and without Rails.
30
+ - Railtie freezes the catalog in `after_initialize`.
31
+
32
+ ### Changed
33
+
34
+ - English is the default language: base catalog messages, the `Unhandled exception` fallback and every text the gem generates (`ConfigurationError`, `ArgumentError`, ...) are in English. The previous Spanish messages remain available in `es.yml` (`c.locale = :es`).
35
+ - Error numbers are integers >= 1 (no octal literals).
36
+ - `ForwardedError` responds with the real status of the received hash.
37
+ - `ZappbException.define_exceptions!` is deprecated: an idempotent no-op.
38
+ - Gemspec summary and description in English; homepage set to the rubygems page.
39
+ - Code comments removed from `lib/`; maintainer documentation lives in `CLAUDE.md`.
40
+
41
+ ### Breaking
42
+
43
+ - A name with `::` no longer implies `external`. Only `external: true` (or `klass:` with a namespaced name) makes an entry external; otherwise the internal class `ZappbException::<A>::<B>` is created. The base entries `ActionView::Template::Error`, `ActiveRecord::RecordInvalid` and `ActionDispatch::Http::Parameters::ParseError` are `external: true`.
44
+ - `error.detail` of foreign exceptions covered by the default safe detail is now the class name.
45
+ - Default message language is English (see "Changed"); services that relied on Spanish base messages must set `c.locale = :es`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BPO Advisors
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,457 @@
1
+ # zappb-exceptions
2
+
3
+ Exception handling for microservices in an MSA architecture that communicate through messaging (NATS, used together
4
+ with the `nats_messaging` gem) and expose HTTP APIs. Each service declares its own error catalog; a stable JSON
5
+ error contract travels between services and to clients. The gem ships only generic HTTP errors: business errors
6
+ always belong to the service that declares them.
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem "zappb-exceptions", "~> 1.0"
13
+ ```
14
+
15
+ Requirements: Ruby >= 3.2 and `activesupport >= 8.0` (Rails 8 or later when you use the Rails integration). The
16
+ `Railtie` is loaded only when `Rails::Railtie` is defined; everything else works without Rails.
17
+
18
+ ## Configuration
19
+
20
+ ```ruby
21
+ # config/initializers/zappb_exception.rb
22
+ ZappbException.configure do |c|
23
+ c.code_prefix = "OR" # "404-OR02" (default "" => "404-02")
24
+ c.service_label = "Orders" # error.detail => "[Orders] ..." (default: the Rails app module name)
25
+ c.meta_key = :orders # key under which this service signs its meta (default nil)
26
+ c.safe_detail = true # true | false | ->(exception) { String }
27
+ c.locale = :es # explicit message locale (default nil => I18n.locale)
28
+ c.logger = Rails.logger # default: Rails.logger, or a Logger on $stdout
29
+
30
+ c.register "OrderNotFound", status: 404, number: 3, message: "Order not found"
31
+ c.register "OrderLocked", status: 409, number: 1, code: "order_locked", message: "Order locked"
32
+ end
33
+ ```
34
+
35
+ | Option | Meaning |
36
+ |---|---|
37
+ | `code_prefix` | Prefix of the visible code, `<status>-<PREFIX><NN>`. Converted with `to_s`. |
38
+ | `service_label` | Label prepended to `error.detail` as `[Label] `. `nil` or empty disables it. |
39
+ | `meta_key` | `nil`, or a non-empty String/Symbol. Signs this service's local meta (see "Meta"). Otherwise `ConfigurationError`. |
40
+ | `safe_detail` | `true`, `false` or a callable. See "Detail sanitization". Otherwise `ConfigurationError`. |
41
+ | `locale` | `nil`, or a non-empty String/Symbol. See "I18n". Otherwise `ConfigurationError`. |
42
+ | `logger` | Logger used when `Rails.event` is not available and for internal failures. |
43
+
44
+ ### `register`
45
+
46
+ ```ruby
47
+ c.register(name, status:, message:, number: nil, code: nil, external: false, klass: nil,
48
+ payload: false, aliases: [], safe_detail: nil, override: false)
49
+ ```
50
+
51
+ | Option | Meaning |
52
+ |---|---|
53
+ | `status` | Integer, 400..599. |
54
+ | `number` | Integer >= 1. `status` + `number` must be unique unless the entry has `code:`. Numbers 90-99 of every status are reserved for the gem's base catalog: services must not use them. |
55
+ | `message` | Default text (required). Can be overridden through I18n. |
56
+ | `code` | Readable visible code (`"order_locked"`) instead of `<status>-<PREFIX><NN>`. |
57
+ | `external` | `true` for a class that is not created by the gem (a library error): it is matched by class name in the resolver and no constant is defined. A `::` in the name does not imply `external`. |
58
+ | `klass` | Adopts an existing class (a Class or a lazy lambda returning one): `klass: -> { CustomFailure }`. A namespaced name with `klass:` is treated as external. |
59
+ | `payload` | `true` (free payload) or an Array of declared keys. See "Raising errors". |
60
+ | `aliases` | Other class names that resolve to this entry. |
61
+ | `safe_detail` | Per-entry override of `config.safe_detail`. |
62
+ | `override` | Required to redefine a base or already registered entry. |
63
+
64
+ Internal entries create `ZappbException::<Name>`, a subclass of `ZappbException::BaseError` (or of `PayloadError`
65
+ when `payload:` is set). Names may be namespaced; each segment must be a valid constant name:
66
+
67
+ ```ruby
68
+ c.register "Billing::InvoiceError", status: 409, number: 3, message: "Invalid invoice"
69
+ c.register "Inventory::Stock::OutOfStock", status: 409, number: 4, message: "Out of stock"
70
+
71
+ raise ZappbException::Billing::InvoiceError, "missing tax id"
72
+ # error.exception => "ZappbException::Billing::InvoiceError"
73
+ ```
74
+
75
+ Intermediate segments are created as modules (or reused when they already exist in `ZappbException`); registering
76
+ `"A"` and `"A::Error"` works in either order. `"Error"` and `"Billing::Error"` are distinct entries.
77
+
78
+ ### `alias_external`
79
+
80
+ ```ruby
81
+ c.alias_external "Vendor::AccessDenied", to: "Forbidden"
82
+ ```
83
+
84
+ Maps an external class (by name, resolved lazily) to an entry that is already registered (base or service), without
85
+ `override:`. Raises `ConfigurationError` if the target does not exist. `error.exception` keeps the real class name.
86
+
87
+ ### Code format and catalog freezing
88
+
89
+ Visible code: `code:` if present, otherwise `"<status>-<PREFIX><NN>"` with `NN` zero-padded (`"404-OR03"`). The
90
+ fallback for unregistered exceptions is `"500-<PREFIX>00"`.
91
+
92
+ The catalog is frozen in `after_initialize` (Railtie) or with `ZappbException.finalize!`: a late `register` raises
93
+ `ZappbException::FrozenRegistryError`. `ZappbException.reset!` restores the base catalog and default configuration
94
+ (meant for tests).
95
+
96
+ ## Raising errors
97
+
98
+ ```ruby
99
+ raise ZappbException::OrderNotFound, "order 7"
100
+ raise ZappbException::Billing::InvoiceError.new("missing tax id", meta: { channel: "sms" })
101
+ raise ZappbException::BadRequest, "missing field"
102
+ ```
103
+
104
+ Payload: with `payload: true` the class inherits from `ZappbException::PayloadError`; keyword arguments are exposed in
105
+ `payload` and merged into `error`:
106
+
107
+ ```ruby
108
+ c.register "OutOfStock", status: 409, number: 4, message: "Out of stock", payload: true
109
+
110
+ raise ZappbException::OutOfStock.new("SKU A1 unavailable", missing: ["A1"]) # error: { exception:, detail:, missing: ["A1"] }
111
+ raise ZappbException::OutOfStock # message => "ZappbException::OutOfStock"
112
+ ```
113
+
114
+ Declared keys (`payload: %i[retry_after]`) generate readers, `extra_data` always includes every declared key (`nil`
115
+ when not given) and an undeclared key raises `ArgumentError`:
116
+
117
+ ```ruby
118
+ c.register "Billing::Throttled", status: 429, number: 5, message: "Rate limited", payload: %i[retry_after]
119
+
120
+ e = ZappbException::Billing::Throttled.new("slow down", retry_after: 3)
121
+ e.retry_after # => 3
122
+ e.extra_data # => { retry_after: 3 }
123
+ ZappbException::Billing::Throttled.new("x", retry_aftr: 3) # ArgumentError
124
+ ```
125
+
126
+ The keys `meta`, `exception` and `detail` (and any name that is already a method of the exception, such as
127
+ `message`) are reserved and raise `ConfigurationError` when registering. `exception` and `detail` can never be
128
+ overwritten by extra data.
129
+
130
+ Meta: pass `meta:` to the constructor, or use `with_meta` for subclasses with their own `initialize`:
131
+
132
+ ```ruby
133
+ raise ZappbException::BadRequest.new("missing field", meta: { channel: "sms" })
134
+ raise MyError.new("x", retry_after: 3).with_meta(foo: 1)
135
+ ```
136
+
137
+ ## Capturing errors
138
+
139
+ All capture points share the same `Envelope` (the JSON shape) and the same notifier (events), and all accept
140
+ `meta:` (local meta).
141
+
142
+ ### Rails controllers
143
+
144
+ ```ruby
145
+ class ApplicationController < ActionController::API
146
+ include ZappbException::Handler
147
+ end
148
+ ```
149
+
150
+ `include ZappbException::Handler` registers a single `rescue_from StandardError`; the resolver walks the exception
151
+ class ancestors and picks the most specific catalog entry. `rescue_from` declarations made after the `include` keep
152
+ priority. To pass meta, declare the rescue explicitly:
153
+
154
+ ```ruby
155
+ rescue_from StandardError do |e|
156
+ ZappbException::Handler.handle(self, e, meta: { request_id: request.request_id })
157
+ end
158
+ ```
159
+
160
+ Anything that does not match the catalog answers `500-<PREFIX>00` ("Unhandled exception").
161
+
162
+ ### Rack middlewares
163
+
164
+ ```ruby
165
+ status, headers, body = ZappbException::Rack.render(exception, meta: { request_id: "abc" })
166
+ # => [404, { "content-type" => "application/json" }, ["{...}"]]
167
+ ```
168
+
169
+ `notify: false` disables event emission.
170
+
171
+ ### NATS listeners
172
+
173
+ ```ruby
174
+ class OrdersListener
175
+ def handle(subject, msg)
176
+ ZappbException::Nats.reply(meta: { request_id: msg["request_id"] }) { process(subject, msg) }
177
+ end
178
+ end
179
+ ```
180
+
181
+ `Nats.reply` returns the block result untouched; if the block raises a `StandardError` it returns the compacted
182
+ error envelope (same shape as `ZappbException.generate_json`) and emits the same events as the HTTP handler
183
+ (`notify: false` to skip). A `ForwardedError` is forwarded with its hash. A failure while notifying is logged and
184
+ never prevents the reply. `rescue_with_handler` at class level also works with the `Handler`.
185
+
186
+ `ZappbException.generate_json(exception, name = exception.class.name, meta: {})` returns the same envelope without
187
+ Rails or a request.
188
+
189
+ ## Sending and reading errors between services
190
+
191
+ The gem does not depend on `nats_messaging`; it works on the response Hash (string or symbol keys).
192
+ `nats_messaging` returns `nil` on timeout or transport error.
193
+
194
+ ```ruby
195
+ response = nats.send_request(subject, payload, 5)
196
+ ZappbException::Nats.raise_on_error!(response, meta: { division_id: 12 })
197
+ ```
198
+
199
+ `Nats.raise_on_error!(response, on_nil: GatewayTimeout, meta: {})`:
200
+
201
+ - `nil` raises `on_nil` (default `ZappbException::GatewayTimeout`, 504-01). `on_nil` accepts an exception class or
202
+ an instance (custom message); anything else raises `ArgumentError`. `meta` is applied when `on_nil` is a
203
+ `BaseError` class or instance.
204
+ - An error response raises `ZappbException::ForwardedError` carrying the response (`meta` is its local meta).
205
+ - Anything else is returned as is.
206
+
207
+ ```ruby
208
+ ZappbException::Nats.raise_on_error!(response, on_nil: ZappbException::ServiceUnavailable.new("inventory is down"))
209
+ ```
210
+
211
+ `Nats.error_response?(response)` is true when `success` is `false` (or `"false"`) or `error` is present and not
212
+ empty. `nil` or a non-Hash is not an error response (it is "no response"), and `error: {}` with `success` other than
213
+ `false` is not an error.
214
+
215
+ ### ForwardedError
216
+
217
+ ```ruby
218
+ begin
219
+ ZappbException::Nats.raise_on_error!(response)
220
+ rescue ZappbException::ForwardedError => e
221
+ e.status # => 409 (Integer or nil)
222
+ e.code # => "409-IV04"
223
+ e.kind?(:OutOfStock) # exact match on the normalized remote class name
224
+ e.detail(strip_label: true) # "[Inventory] SKU A1 unavailable" => "SKU A1 unavailable"
225
+ e.extra # => { missing: ["A1"] }
226
+ e.remote # RemoteError view of the response
227
+ e.remote_meta # meta of the remote hash (#meta is this exception's local meta)
228
+ raise # re-raising forwards the remote code and status unchanged
229
+ end
230
+ ```
231
+
232
+ `ForwardedError.new("status" => 404, ...)` (a hash without braces) arrives as keyword arguments, so a `meta` key is
233
+ read as local meta; pass the hash in braces to forward a remote `meta`.
234
+
235
+ ### RemoteError
236
+
237
+ ```ruby
238
+ err = ZappbException::RemoteError.from(response) # never raises
239
+ err.no_response? # nil or a non-Hash (timeout)
240
+ err.error?
241
+ err.ok? # "is not an error response": tolerates responses without `success`
242
+ err.success? # strict: true only for a Hash with success true or "true"
243
+ err.status # Integer or nil
244
+ err.code
245
+ err.message
246
+ err.exception_name # without the "ZappbException::" prefix
247
+ err.kind?(:OutOfStock, "ZappbException::NotFound")
248
+ err.detail(strip_label: true)
249
+ err.extra # fields of `error` other than exception/detail, Symbol keys
250
+ err.meta # meta as received ({} if absent)
251
+ err.to_forwarded_error # ForwardedError (GatewayTimeout when there was no response)
252
+ ```
253
+
254
+ `kind?` compares the complete normalized name: `kind?("Billing::InvoiceError")` matches, `kind?("InvoiceError")`
255
+ does not. `ok?` and `success?` are not equivalent: use `success?` when you are going to consume the result.
256
+ Without a response `RemoteError.from` returns `RemoteError::NoResponse` (a null object: `error?`, `ok?` and
257
+ `success?` are false, the rest is `nil`/`{}`).
258
+
259
+ ### Meta
260
+
261
+ Meta is context information added by services at the point of use; the gem never invents it.
262
+
263
+ - Local meta: the `meta:` keyword of each capture point and/or the exception's own meta (`BaseError#meta`); when
264
+ both exist they are merged and the keyword wins.
265
+ - With `config.meta_key` set, local meta is signed: `{ meta_key => local }`. Without `meta_key` (an edge service) it
266
+ goes at the root of `meta`.
267
+ - When a `ForwardedError` is forwarded, the remote meta is kept and the signed local meta is merged into it; if a
268
+ key exists on both sides and both values are Hashes they are merged (local wins), otherwise local wins.
269
+ - Empty final meta: the response has no `meta` key. Non-Hash values are ignored; keys are normalized to Symbols.
270
+
271
+ A chain with fictitious services (`inventory` signs with `:inventory`, `billing` with `:billing`, an edge service
272
+ without `meta_key`):
273
+
274
+ ```ruby
275
+ # inventory replies: meta: { inventory: { channel: "sms" } }
276
+ # billing forwards it with meta: { division_id: 12 }
277
+ # meta: { inventory: { channel: "sms" }, billing: { division_id: 12 } }
278
+ # the edge forwards it with meta: { request_id: "abc" }
279
+ # meta: { request_id: "abc", inventory: { ... }, billing: { ... } }
280
+ ```
281
+
282
+ ## The JSON contract
283
+
284
+ ```json
285
+ {
286
+ "success": false,
287
+ "status": 409,
288
+ "code": "409-IV04",
289
+ "message": "Out of stock",
290
+ "error": {
291
+ "exception": "ZappbException::OutOfStock",
292
+ "detail": "[Inventory] SKU A1 unavailable",
293
+ "missing": ["A1"]
294
+ },
295
+ "meta": { "inventory": { "warehouse": 3 } }
296
+ }
297
+ ```
298
+
299
+ - `success` is always `false`; `status` is the HTTP status; `code` the visible code; `message` the localized text.
300
+ - `error.exception` is the class name (`ZappbException::` prefix for internal entries); `error.detail` is the
301
+ sanitized message with the service label; extra fields come from the payload of the exception.
302
+ - `meta` appears only when there is meta. `nil` keys are removed from the top level of the response.
303
+
304
+ A forwarded error keeps the remote body and status, and merges the meta of every hop. Forwarded by `orders`
305
+ (`meta_key :orders`) with `meta: { order_id: 7 }`:
306
+
307
+ ```json
308
+ {
309
+ "success": false,
310
+ "status": 409,
311
+ "code": "409-IV04",
312
+ "message": "Out of stock",
313
+ "error": {
314
+ "exception": "ZappbException::OutOfStock",
315
+ "detail": "[Inventory] SKU A1 unavailable",
316
+ "missing": ["A1"]
317
+ },
318
+ "meta": { "inventory": { "warehouse": 3 }, "orders": { "order_id": 7 } }
319
+ }
320
+ ```
321
+
322
+ ## Base catalog
323
+
324
+ Generic HTTP errors only. Numbers 90-99 are reserved for new base entries. Messages are shown in English; the
325
+ gem also ships `es` translations (see "I18n").
326
+
327
+ | Name | Code | Message | Aliases |
328
+ |---|---|---|---|
329
+ | Exception | 500-01 | Server error | |
330
+ | NameError | 500-02 | Method not implemented | |
331
+ | NoMethodError | 500-03 | Method not implemented | |
332
+ | Wrong200 | 500-04 | Remote server error | |
333
+ | TokenError | 500-05 | Remote server connection error | |
334
+ | ActionView::Template::Error | 500-06 | Error rendering response | (external) |
335
+ | MissingConfiguration | 500-90 | Incomplete service configuration | |
336
+ | BadRequest | 400-01 | Invalid input parameters | ActionController::ParameterMissing |
337
+ | ArgumentError | 400-02 | Invalid input parameters | |
338
+ | ActiveRecord::RecordInvalid | 400-03 | Invalid input parameters | (external) |
339
+ | ActionDispatch::Http::Parameters::ParseError | 400-04 | Invalid input parameters | (external) |
340
+ | Unauthorized | 401-01 | Authentication error | |
341
+ | Forbidden | 403-01 | You do not have permission to perform this action | |
342
+ | NotFound | 404-01 | Page not found | |
343
+ | RecordNotFound | 404-02 | Resource not available | ActiveRecord::RecordNotFound |
344
+ | PayloadTooLarge | 413-01 | Request body exceeds the maximum allowed size | |
345
+ | ServiceUnavailable | 503-01 | Service temporarily unavailable | |
346
+ | GatewayTimeout | 504-01 | Timeout | NoResponseError, NatsTimeoutError |
347
+ | (fallback) | 500-00 | Unhandled exception | any other exception |
348
+
349
+ `Conflict` (409) and `UnprocessableEntity` (422) are not in the base: declare them in your service if needed.
350
+ `Exception` only matches `ZappbException::Exception`; any other `StandardError` falls into the fallback.
351
+
352
+ ## Detail sanitization
353
+
354
+ `error.detail` is the exception message prefixed with the service label. `safe_detail` controls what is exposed:
355
+
356
+ - `false`: the exception message.
357
+ - `true`: a `BaseError` shows its message; any other exception shows its class name.
358
+ - callable: `->(exception) { String }`.
359
+
360
+ Precedence: the entry's `safe_detail` wins over `config.safe_detail`; the global one applies to entries registered
361
+ without `safe_detail:`. The base catalog hides foreign exception details by default (`safe_detail: true`) in
362
+ `Exception`, `NameError`, `NoMethodError`, `Wrong200`, `TokenError`, `ActionView::Template::Error`,
363
+ `RecordNotFound`, `ServiceUnavailable` and `GatewayTimeout`, and `MissingConfiguration` always answers the class name.
364
+ The fallback also hides the detail unless the service sets its own global `safe_detail` (`true` or callable).
365
+ `ActiveRecord::RecordInvalid` is deliberately not hidden. Events and logs always receive the full message.
366
+
367
+ ## Events and logging
368
+
369
+ Events are published through `Rails.event`; when it is not available, one JSON line is written to the logger.
370
+
371
+ - `zappb_exception.error`: `exception_class`, `http_status`, `error_code`, `message`, `backtrace` (10 lines for
372
+ status >= 500, 1 otherwise), `meta` and `extra` (omitted when empty).
373
+ - `zappb_exception.forwarded_error`: `http_status`, `error_code`, `message`, `meta` and `extra` (read from the remote
374
+ hash; omitted when empty).
375
+
376
+ Level: `error` for status >= 500, `warn` otherwise. The same function is public:
377
+
378
+ ```ruby
379
+ ZappbException.log_level(exception) # => :warn or :error
380
+ ZappbException.log_level(forwarded_error) # uses the remote status
381
+ ```
382
+
383
+ ## I18n
384
+
385
+ Base messages are translated in `config/locales/en.yml` and `config/locales/es.yml` under
386
+ `zappb_exception.errors.<Name>`, and both files are added to `I18n.load_path` (with or without Rails). The literal
387
+ `message:` of an entry is the default when no translation exists.
388
+
389
+ - Default: the message uses `I18n.locale`.
390
+ - `c.locale = :es` forces that locale for every message the gem produces, regardless of `I18n.locale`
391
+ (`I18n.t(..., locale:)`). The value is a Symbol or String (or `nil` to follow `I18n.locale`).
392
+ - Regional locales such as `es-CL` have no translation file in the gem: set `c.locale = :es` (or add your own
393
+ keys for the regional locale).
394
+ - A service overrides any text with its own locale files, using the entry name as key (full name with `::` for
395
+ namespaced entries, quoted in YAML):
396
+
397
+ ```yaml
398
+ es:
399
+ zappb_exception:
400
+ errors:
401
+ "OrderNotFound": "Pedido no encontrado"
402
+ "Billing::InvoiceError": "Factura invalida"
403
+ ```
404
+
405
+ ## `ZappbException.describe`
406
+
407
+ ```ruby
408
+ ZappbException.describe(exception)
409
+ # => #<data ZappbException::Describer::Description status=409, code="409-IV04", message="Out of stock">
410
+ ```
411
+
412
+ Returns `status`, `code` and `message` according to the catalog (for a `ForwardedError`, the remote values) and
413
+ `#level`. It does not notify and does not expose the class name; meant for jobs that persist an error.
414
+
415
+ ## Testing helpers
416
+
417
+ Mocks for NATS responses (no RSpec required):
418
+
419
+ ```ruby
420
+ require "zappb_exception/testing"
421
+
422
+ ZappbException::Testing.error_response(:OutOfStock, detail: "none", extra: { missing: ["A1"] },
423
+ meta: { inventory: { channel: "sms" } })
424
+ # => { success: false, status: 409, code: "409-IV04", message: "Out of stock",
425
+ # error: { exception: "ZappbException::OutOfStock", detail: "[Inventory] none", missing: ["A1"] },
426
+ # meta: { inventory: { channel: "sms" } } }
427
+
428
+ ZappbException::Testing.success_response(id: 1)
429
+ # => { success: true, status: 200, message: nil, data: { id: 1 }, meta: {} }
430
+ ```
431
+
432
+ `error_response` uses the loaded catalog (prefix and label of the configuration) and raises `ArgumentError` for an
433
+ unregistered name.
434
+
435
+ RSpec matchers:
436
+
437
+ ```ruby
438
+ require "zappb_exception/rspec"
439
+
440
+ expect(error).to be_zappb_error(:BadRequest) # resolves through the catalog (aliases, subclasses)
441
+ expect { call }.to raise_zappb_error(:NotFound, /order/) # optional message
442
+ expect(json).to match_error_envelope(code: "404-OR02", status: 404, exception: :OutOfStock)
443
+ ```
444
+
445
+ `match_error_envelope` is partial: it accepts a Hash (string or symbol keys) or a JSON String and the keys `code`,
446
+ `status`, `message`, `exception`, `detail` and `meta`; values may be matchers such as regular expressions.
447
+
448
+ ## Development
449
+
450
+ ```
451
+ bundle install
452
+ bundle exec rspec
453
+ ```
454
+
455
+ ## License
456
+
457
+ MIT. See `LICENSE.txt`.
@@ -0,0 +1,22 @@
1
+ en:
2
+ zappb_exception:
3
+ errors:
4
+ "Exception": "Server error"
5
+ "NameError": "Method not implemented"
6
+ "NoMethodError": "Method not implemented"
7
+ "Wrong200": "Remote server error"
8
+ "TokenError": "Remote server connection error"
9
+ "ActionView::Template::Error": "Error rendering response"
10
+ "MissingConfiguration": "Incomplete service configuration"
11
+ "BadRequest": "Invalid input parameters"
12
+ "ArgumentError": "Invalid input parameters"
13
+ "ActiveRecord::RecordInvalid": "Invalid input parameters"
14
+ "ActionDispatch::Http::Parameters::ParseError": "Invalid input parameters"
15
+ "Unauthorized": "Authentication error"
16
+ "Forbidden": "You do not have permission to perform this action"
17
+ "NotFound": "Page not found"
18
+ "RecordNotFound": "Resource not available"
19
+ "PayloadTooLarge": "Request body exceeds the maximum allowed size"
20
+ "ServiceUnavailable": "Service temporarily unavailable"
21
+ "GatewayTimeout": "Timeout"
22
+ Unhandled: "Unhandled exception"
@@ -0,0 +1,22 @@
1
+ es:
2
+ zappb_exception:
3
+ errors:
4
+ "Exception": "Error en el servidor"
5
+ "NameError": "Método no implementado"
6
+ "NoMethodError": "Método no implementado"
7
+ "Wrong200": "Error en servidor remoto"
8
+ "TokenError": "Error de conexión a servidor remoto"
9
+ "ActionView::Template::Error": "Error al renderizar respuesta"
10
+ "MissingConfiguration": "Configuración del servicio incompleta"
11
+ "BadRequest": "Error en parámetros de entrada"
12
+ "ArgumentError": "Error en parámetros de entrada"
13
+ "ActiveRecord::RecordInvalid": "Error en parámetros de entrada"
14
+ "ActionDispatch::Http::Parameters::ParseError": "Error en parámetros de entrada"
15
+ "Unauthorized": "Error de autenticación"
16
+ "Forbidden": "No cuentas con los permisos necesarios para realizar esta acción"
17
+ "NotFound": "Página no encontrada"
18
+ "RecordNotFound": "Recurso no disponible"
19
+ "PayloadTooLarge": "El cuerpo del request excede el tamaño máximo permitido"
20
+ "ServiceUnavailable": "Servicio temporalmente no disponible"
21
+ "GatewayTimeout": "Timeout"
22
+ Unhandled: "Excepción no controlada"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zappb_exception"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ BASE_CATALOG = [
5
+ { name: "Exception", status: 500, number: 1, message: "Server error", safe_detail: true },
6
+ { name: "NameError", status: 500, number: 2, message: "Method not implemented", safe_detail: true },
7
+ { name: "NoMethodError", status: 500, number: 3, message: "Method not implemented", safe_detail: true },
8
+ { name: "Wrong200", status: 500, number: 4, message: "Remote server error", safe_detail: true },
9
+ { name: "TokenError", status: 500, number: 5, message: "Remote server connection error", safe_detail: true },
10
+ { name: "ActionView::Template::Error", status: 500, number: 6, message: "Error rendering response",
11
+ external: true, safe_detail: true },
12
+ { name: "MissingConfiguration", status: 500, number: 90, message: "Incomplete service configuration",
13
+ safe_detail: ->(exception) { exception.class.name.to_s } },
14
+ { name: "BadRequest", status: 400, number: 1, message: "Invalid input parameters",
15
+ aliases: ["ActionController::ParameterMissing"] },
16
+ { name: "ArgumentError", status: 400, number: 2, message: "Invalid input parameters" },
17
+ { name: "ActiveRecord::RecordInvalid", status: 400, number: 3, message: "Invalid input parameters",
18
+ external: true },
19
+ { name: "ActionDispatch::Http::Parameters::ParseError", status: 400, number: 4,
20
+ message: "Invalid input parameters", external: true },
21
+ { name: "Unauthorized", status: 401, number: 1, message: "Authentication error" },
22
+ { name: "Forbidden", status: 403, number: 1,
23
+ message: "You do not have permission to perform this action" },
24
+ { name: "NotFound", status: 404, number: 1, message: "Page not found" },
25
+ { name: "RecordNotFound", status: 404, number: 2, message: "Resource not available",
26
+ safe_detail: true, aliases: ["ActiveRecord::RecordNotFound"] },
27
+ { name: "PayloadTooLarge", status: 413, number: 1,
28
+ message: "Request body exceeds the maximum allowed size" },
29
+ { name: "ServiceUnavailable", status: 503, number: 1, message: "Service temporarily unavailable",
30
+ safe_detail: true },
31
+ { name: "GatewayTimeout", status: 504, number: 1, message: "Timeout", safe_detail: true,
32
+ aliases: %w[NoResponseError NatsTimeoutError] }
33
+ ].freeze
34
+ end