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 +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +457 -0
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/lib/zappb/exceptions.rb +3 -0
- data/lib/zappb_exception/base_catalog.rb +34 -0
- data/lib/zappb_exception/class_factory.rb +73 -0
- data/lib/zappb_exception/configuration.rb +86 -0
- data/lib/zappb_exception/definition.rb +101 -0
- data/lib/zappb_exception/describer.rb +33 -0
- data/lib/zappb_exception/detail_sanitizer.rb +18 -0
- data/lib/zappb_exception/envelope.rb +117 -0
- data/lib/zappb_exception/errors.rb +95 -0
- data/lib/zappb_exception/handler.rb +30 -0
- data/lib/zappb_exception/nats.rb +49 -0
- data/lib/zappb_exception/notifier.rb +75 -0
- data/lib/zappb_exception/rack.rb +15 -0
- data/lib/zappb_exception/railtie.rb +11 -0
- data/lib/zappb_exception/registry.rb +191 -0
- data/lib/zappb_exception/remote_error.rb +133 -0
- data/lib/zappb_exception/resolver.rb +17 -0
- data/lib/zappb_exception/rspec.rb +102 -0
- data/lib/zappb_exception/testing.rb +41 -0
- data/lib/zappb_exception/version.rb +5 -0
- data/lib/zappb_exception.rb +96 -0
- metadata +83 -0
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,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
|