domain-errors 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {domain_errors-0.1.0 → domain_errors-0.2.0}/CHANGELOG.md +9 -3
- {domain_errors-0.1.0 → domain_errors-0.2.0}/CONTRIBUTING.md +2 -2
- {domain_errors-0.1.0 → domain_errors-0.2.0}/PKG-INFO +100 -26
- {domain_errors-0.1.0 → domain_errors-0.2.0}/README.md +99 -25
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/chain.md +10 -10
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/cloud.md +4 -4
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/diagrams.md +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/http.md +4 -4
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/python.md +8 -8
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/wrap_errors.md +63 -10
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-13-chain-security-audit.md +2 -2
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-14-wrap_errors-security-audit.md +5 -5
- domain_errors-0.2.0/docs/reviews/2026-06-13-chain-review.md +49 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/2026-06-13-domain_error-review.md +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/2026-06-14-http-review.md +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/2026-06-14-python-review.md +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/2026-06-14-wrap_errors-review.md +38 -38
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/config/_version.py +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/tests/test_wrap_errors/test_wrap_errors_client.py +342 -0
- domain_errors-0.2.0/domain_errors/decorators/wrap_errors/wrap_errors_client.py +165 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/tests/test_chain/conftest.py +1 -1
- {domain_errors-0.1.0 → domain_errors-0.2.0}/pyproject.toml +1 -0
- domain_errors-0.1.0/docs/reviews/2026-06-13-chain-review.md +0 -49
- domain_errors-0.1.0/domain_errors/decorators/wrap_errors/wrap_errors_client.py +0 -95
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/ci.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/cleanup-guard.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/dto-strict.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/no-ai-attribution.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/publish.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.github/workflows/ruff.yml +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/.gitignore +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/LICENSE +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/SECURITY.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/apps/domain_error.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/architecture/.gitkeep +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/.gitkeep +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-13-domain_error-security-audit.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-14-cloud-security-audit.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-14-http-security-audit.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/audits/2026-06-14-python-security-audit.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/.gitkeep +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/docs/reviews/2026-06-14-cloud-review.md +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/common/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/common/constants/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/common/constants/tests.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/common/tests/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/config/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/tests/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/tests/test_wrap_errors/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/tests/test_wrap_errors/conftest.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/decorators/wrap_errors/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/cloud/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/cloud/cloud_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/constants/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/constants/cloud.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/constants/domain_error.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/constants/http.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/constants/python.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/domain_error/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/domain_error/domain_error.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/http/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/http/http_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/python/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/python/python_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_cloud/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_cloud/conftest.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_cloud/test_cloud_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_domain_error/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_domain_error/conftest.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_domain_error/test_domain_error.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_http/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_http/conftest.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_http/test_http_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_python/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_python/conftest.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/domains/tests/test_python/test_python_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/py.typed +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/chain/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/chain/chain_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/chain/chain_objects.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/constants/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/constants/chain.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/constants/services.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/tests/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/tests/test_chain/__init__.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/tests/test_chain/test_chain_client.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/domain_errors/services/tests/test_chain/test_chain_objects.py +0 -0
- {domain_errors-0.1.0 → domain_errors-0.2.0}/uv.lock +0 -0
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **@wrap_errors class-level decoration:** Decorator can now be applied to classes. Fans out over all public callables in `cls.__dict__`, preserving sync/async dispatch per method. Skips private methods, dunder methods, properties, nested classes, and already-decorated methods. Unwraps and rewraps classmethod/staticmethod to preserve semantics. Config (catch, as_, message, capture) applies uniformly to all fanned methods.
|
|
15
|
+
|
|
10
16
|
## [0.1.0] - 2026-06-15
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -14,9 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
20
|
- **DomainError base class:** Typed exception hierarchy with class contract (code, domain, http_status, retryable, default_message).
|
|
15
21
|
- **Instance state:** message (string) and context (dict) for structured logging.
|
|
16
22
|
- **ErrorChain service:** Static methods for wrapping, walking, and analyzing exception chains.
|
|
17
|
-
- `wrap(err, as_=ErrorType, message=None, **context)
|
|
18
|
-
- `history(err, classifiers=())
|
|
19
|
-
- `crossings(err, classifiers=())
|
|
23
|
+
- `wrap(err, as_=ErrorType, message=None, **context)`: construct typed domain errors.
|
|
24
|
+
- `history(err, classifiers=())`: walk exception cascade into ChainLink objects.
|
|
25
|
+
- `crossings(err, classifiers=())`: identify domain boundary crossings.
|
|
20
26
|
- **ChainLink value object:** Structured representation of one exception chain hop, with `to_log_extra()` for JSON-ready logging.
|
|
21
27
|
- **DomainCrossing value object:** Causation hop where errors cross domain boundaries, with `to_log_extra()` for logging.
|
|
22
28
|
- **ChainVia enum:** Tags for how a link entered the chain (ROOT, CAUSE, CONTEXT).
|
|
@@ -42,13 +42,13 @@ This project follows these conventions:
|
|
|
42
42
|
- **Dataclasses**: Use `@dataclass(frozen=True, slots=True)` for immutable value types.
|
|
43
43
|
- **Docstrings**: One-line docstrings for modules, classes, and functions. Use triple quotes even for single lines.
|
|
44
44
|
- **Imports**: Import abstract base classes from `collections.abc` (not `typing`). Use absolute imports (no relative imports).
|
|
45
|
-
- **Methods**: Every method lives in a class or container
|
|
45
|
+
- **Methods**: Every method lives in a class or container: no module-level functions. Containers keep methods organized.
|
|
46
46
|
- **Constants**: Extract magic constants to `constants/` directory only when they are semantically significant (shared across modules or define behavior).
|
|
47
47
|
|
|
48
48
|
## Pull Request Flow
|
|
49
49
|
|
|
50
50
|
1. **Branch**: Create a feature branch off `main` (e.g., `git checkout -b feature/my-feature`).
|
|
51
|
-
2. **Commit**: Make small, clear commits. Each commit author is the person who wrote the code
|
|
51
|
+
2. **Commit**: Make small, clear commits. Each commit author is the person who wrote the code: no co-author or AI attribution trailers.
|
|
52
52
|
3. **Push**: Push your branch and open a pull request on GitHub.
|
|
53
53
|
4. **Review**: The maintainer will review your code, request changes if needed, and merge when ready. Contributors do not merge their own PRs.
|
|
54
54
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: domain-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Typed domain error hierarchy with wrapping and chaining for Python services
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/domain-errors/
|
|
6
6
|
Project-URL: Repository, https://github.com/jekhator/domain-errors
|
|
@@ -29,11 +29,11 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
Typed domain error hierarchy with wrapping and chaining for Python services.
|
|
31
31
|
|
|
32
|
-
Define per-domain exception types with a structured contract (code, domain, HTTP status, retryability). Wrap foreign exceptions into domain errors, walk causation chains, and detect when errors cross domain boundaries
|
|
32
|
+
Define per-domain exception types with a structured contract (code, domain, HTTP status, retryability). Wrap foreign exceptions into domain errors, walk causation chains, and detect when errors cross domain boundaries: all ready for structured logging.
|
|
33
33
|
|
|
34
34
|
## Why?
|
|
35
35
|
|
|
36
|
-
Service errors should carry semantic meaning: *what* failed (code), *where* it failed (domain), *how* to handle it (HTTP status, retryability), and *why* it happened (context). Cross-service calls risk mixing domains
|
|
36
|
+
Service errors should carry semantic meaning: *what* failed (code), *where* it failed (domain), *how* to handle it (HTTP status, retryability), and *why* it happened (context). Cross-service calls risk mixing domains: database errors become API errors become payment errors. `domain-errors` makes this hierarchy explicit and traceable.
|
|
37
37
|
|
|
38
38
|
```python
|
|
39
39
|
from domain_errors import DomainError, ErrorChain
|
|
@@ -165,17 +165,17 @@ error = MyError(
|
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
**Attributes:**
|
|
168
|
-
- `message: str
|
|
169
|
-
- `context: dict[str, object]
|
|
168
|
+
- `message: str`: the error message
|
|
169
|
+
- `context: dict[str, object]`: structured context data
|
|
170
170
|
|
|
171
171
|
### ErrorChain
|
|
172
172
|
|
|
173
173
|
Static methods for wrapping, walking, and analyzing exception chains.
|
|
174
174
|
|
|
175
175
|
**Methods:**
|
|
176
|
-
- `ErrorChain.wrap(err, as_=ErrorType, message=None, **context)
|
|
177
|
-
- `ErrorChain.history(err, classifiers=())
|
|
178
|
-
- `ErrorChain.crossings(err, classifiers=())
|
|
176
|
+
- `ErrorChain.wrap(err, as_=ErrorType, message=None, **context)`: Construct a typed domain error for the caller to raise `from err`.
|
|
177
|
+
- `ErrorChain.history(err, classifiers=())`: Walk the exception chain; return a tuple of `ChainLink` objects.
|
|
178
|
+
- `ErrorChain.crossings(err, classifiers=())`: Find causation hops where errors crossed domains; return a tuple of `DomainCrossing` objects.
|
|
179
179
|
|
|
180
180
|
### ChainLink
|
|
181
181
|
|
|
@@ -245,7 +245,9 @@ links = ErrorChain.history(err, classifiers=(python, http, cloud))
|
|
|
245
245
|
|
|
246
246
|
### @wrap_errors
|
|
247
247
|
|
|
248
|
-
Decorator for automatic error wrapping. Catches specified exceptions, wraps them into a target DomainError, and captures function arguments for structured logging
|
|
248
|
+
Decorator for automatic error wrapping. Catches specified exceptions, wraps them into a target DomainError, and captures function arguments for structured logging. Works on both functions and classes (fans out to all public methods, preserving sync/async dispatch).
|
|
249
|
+
|
|
250
|
+
**Sync and async functions:**
|
|
249
251
|
|
|
250
252
|
```python
|
|
251
253
|
from domain_errors import DomainError, wrap_errors
|
|
@@ -260,18 +262,6 @@ def read_config(path: str) -> str:
|
|
|
260
262
|
with open(path) as f:
|
|
261
263
|
return f.read()
|
|
262
264
|
|
|
263
|
-
# Manual wrapping (before)
|
|
264
|
-
try:
|
|
265
|
-
config = read_config("/etc/app.yaml")
|
|
266
|
-
except FileNotFoundError as e:
|
|
267
|
-
raise ErrorChain.wrap(e, as_=StorageError, path=path) from e
|
|
268
|
-
|
|
269
|
-
# Declarative wrapping (after)
|
|
270
|
-
@wrap_errors(StorageError, catch=(FileNotFoundError, IOError))
|
|
271
|
-
def read_config(path: str) -> str:
|
|
272
|
-
with open(path) as f:
|
|
273
|
-
return f.read()
|
|
274
|
-
|
|
275
265
|
try:
|
|
276
266
|
config = read_config("/etc/app.yaml")
|
|
277
267
|
except StorageError as err:
|
|
@@ -280,18 +270,102 @@ except StorageError as err:
|
|
|
280
270
|
pass
|
|
281
271
|
```
|
|
282
272
|
|
|
273
|
+
**Service classes (class-level wrapping):**
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
from domain_errors import DomainError, wrap_errors
|
|
277
|
+
|
|
278
|
+
class PaymentError(DomainError):
|
|
279
|
+
code = "payment_error"
|
|
280
|
+
domain = "payment"
|
|
281
|
+
http_status = 402
|
|
282
|
+
|
|
283
|
+
@wrap_errors(PaymentError, catch=(ValueError,))
|
|
284
|
+
class PaymentService:
|
|
285
|
+
def validate_amount(self, amount: int) -> bool:
|
|
286
|
+
if amount <= 0:
|
|
287
|
+
raise ValueError("Amount must be positive")
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
async def process_payment(self, card: str, amount: int) -> str:
|
|
291
|
+
if not card:
|
|
292
|
+
raise ValueError("Card required")
|
|
293
|
+
return f"Processed {amount}"
|
|
294
|
+
|
|
295
|
+
service = PaymentService()
|
|
296
|
+
|
|
297
|
+
assert service.validate_amount(100)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
service.validate_amount(-50)
|
|
301
|
+
except PaymentError as err:
|
|
302
|
+
print(f"Error: {err.message}")
|
|
303
|
+
print(f"Context: {err.context}")
|
|
304
|
+
print(f"Cause: {err.__cause__}")
|
|
305
|
+
```
|
|
306
|
+
|
|
283
307
|
DomainError instances pass through unchanged; works on sync and async callables; set `capture=False` to omit arguments (e.g., for secrets).
|
|
284
308
|
|
|
309
|
+
## Service Class Example
|
|
310
|
+
|
|
311
|
+
A complete service class pattern with error wrapping and context capture:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from domain_errors import DomainError, ErrorChain
|
|
315
|
+
|
|
316
|
+
class PaymentService:
|
|
317
|
+
@staticmethod
|
|
318
|
+
def process_payment(amount_cents: int) -> dict:
|
|
319
|
+
try:
|
|
320
|
+
if amount_cents <= 0:
|
|
321
|
+
raise ValueError("Amount must be positive")
|
|
322
|
+
if amount_cents > 999999:
|
|
323
|
+
raise ValueError("Amount exceeds maximum")
|
|
324
|
+
return {"status": "success", "amount_cents": amount_cents}
|
|
325
|
+
except ValueError as e:
|
|
326
|
+
raise ErrorChain.wrap(
|
|
327
|
+
e,
|
|
328
|
+
as_=PaymentError,
|
|
329
|
+
message=f"Payment validation failed: {e}",
|
|
330
|
+
amount_cents=amount_cents
|
|
331
|
+
) from e
|
|
332
|
+
|
|
333
|
+
class PaymentError(DomainError):
|
|
334
|
+
code = "payment_error"
|
|
335
|
+
domain = "payment"
|
|
336
|
+
http_status = 400
|
|
337
|
+
default_message = "Payment processing failed."
|
|
338
|
+
|
|
339
|
+
# Usage
|
|
340
|
+
try:
|
|
341
|
+
result = PaymentService.process_payment(5000)
|
|
342
|
+
print(f"Success: {result}")
|
|
343
|
+
except PaymentError as err:
|
|
344
|
+
print(f"Error {err.code} in {err.domain}: {err.message}")
|
|
345
|
+
print(f"Context: {err.context}")
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Output:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
✓ Successful payment: {'status': 'success', 'amount_cents': 5000}
|
|
352
|
+
✓ Caught PaymentError: code=payment_error, domain=payment
|
|
353
|
+
Message: Payment validation failed: Amount must be positive
|
|
354
|
+
Context: {'amount_cents': -100}
|
|
355
|
+
Chain length: 2
|
|
356
|
+
✓ Caught PaymentError: amount_cents=9999999
|
|
357
|
+
```
|
|
358
|
+
|
|
285
359
|
## Documentation
|
|
286
360
|
|
|
287
|
-
- **DomainError Base:** [docs/apps/domain_error.md](docs/apps/domain_error.md)
|
|
288
|
-
- **ErrorChain:** [docs/apps/chain.md](docs/apps/chain.md)
|
|
289
|
-
- **@wrap_errors Decorator:** [docs/apps/wrap_errors.md](docs/apps/wrap_errors.md)
|
|
290
|
-
- **Architecture:** [docs/apps/diagrams.md](docs/apps/diagrams.md)
|
|
361
|
+
- **DomainError Base:** [docs/apps/domain_error.md](docs/apps/domain_error.md): class contract, subclassing, message/context semantics
|
|
362
|
+
- **ErrorChain:** [docs/apps/chain.md](docs/apps/chain.md): wrap, history, crossings, logging integration
|
|
363
|
+
- **@wrap_errors Decorator:** [docs/apps/wrap_errors.md](docs/apps/wrap_errors.md): declarative error wrapping for functions and async callables
|
|
364
|
+
- **Architecture:** [docs/apps/diagrams.md](docs/apps/diagrams.md): data flow and domain relationships
|
|
291
365
|
|
|
292
366
|
## License
|
|
293
367
|
|
|
294
|
-
Apache 2.0
|
|
368
|
+
Apache 2.0; see LICENSE file.
|
|
295
369
|
|
|
296
370
|
## Contributing
|
|
297
371
|
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
Typed domain error hierarchy with wrapping and chaining for Python services.
|
|
9
9
|
|
|
10
|
-
Define per-domain exception types with a structured contract (code, domain, HTTP status, retryability). Wrap foreign exceptions into domain errors, walk causation chains, and detect when errors cross domain boundaries
|
|
10
|
+
Define per-domain exception types with a structured contract (code, domain, HTTP status, retryability). Wrap foreign exceptions into domain errors, walk causation chains, and detect when errors cross domain boundaries: all ready for structured logging.
|
|
11
11
|
|
|
12
12
|
## Why?
|
|
13
13
|
|
|
14
|
-
Service errors should carry semantic meaning: *what* failed (code), *where* it failed (domain), *how* to handle it (HTTP status, retryability), and *why* it happened (context). Cross-service calls risk mixing domains
|
|
14
|
+
Service errors should carry semantic meaning: *what* failed (code), *where* it failed (domain), *how* to handle it (HTTP status, retryability), and *why* it happened (context). Cross-service calls risk mixing domains: database errors become API errors become payment errors. `domain-errors` makes this hierarchy explicit and traceable.
|
|
15
15
|
|
|
16
16
|
```python
|
|
17
17
|
from domain_errors import DomainError, ErrorChain
|
|
@@ -143,17 +143,17 @@ error = MyError(
|
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
**Attributes:**
|
|
146
|
-
- `message: str
|
|
147
|
-
- `context: dict[str, object]
|
|
146
|
+
- `message: str`: the error message
|
|
147
|
+
- `context: dict[str, object]`: structured context data
|
|
148
148
|
|
|
149
149
|
### ErrorChain
|
|
150
150
|
|
|
151
151
|
Static methods for wrapping, walking, and analyzing exception chains.
|
|
152
152
|
|
|
153
153
|
**Methods:**
|
|
154
|
-
- `ErrorChain.wrap(err, as_=ErrorType, message=None, **context)
|
|
155
|
-
- `ErrorChain.history(err, classifiers=())
|
|
156
|
-
- `ErrorChain.crossings(err, classifiers=())
|
|
154
|
+
- `ErrorChain.wrap(err, as_=ErrorType, message=None, **context)`: Construct a typed domain error for the caller to raise `from err`.
|
|
155
|
+
- `ErrorChain.history(err, classifiers=())`: Walk the exception chain; return a tuple of `ChainLink` objects.
|
|
156
|
+
- `ErrorChain.crossings(err, classifiers=())`: Find causation hops where errors crossed domains; return a tuple of `DomainCrossing` objects.
|
|
157
157
|
|
|
158
158
|
### ChainLink
|
|
159
159
|
|
|
@@ -223,7 +223,9 @@ links = ErrorChain.history(err, classifiers=(python, http, cloud))
|
|
|
223
223
|
|
|
224
224
|
### @wrap_errors
|
|
225
225
|
|
|
226
|
-
Decorator for automatic error wrapping. Catches specified exceptions, wraps them into a target DomainError, and captures function arguments for structured logging
|
|
226
|
+
Decorator for automatic error wrapping. Catches specified exceptions, wraps them into a target DomainError, and captures function arguments for structured logging. Works on both functions and classes (fans out to all public methods, preserving sync/async dispatch).
|
|
227
|
+
|
|
228
|
+
**Sync and async functions:**
|
|
227
229
|
|
|
228
230
|
```python
|
|
229
231
|
from domain_errors import DomainError, wrap_errors
|
|
@@ -238,18 +240,6 @@ def read_config(path: str) -> str:
|
|
|
238
240
|
with open(path) as f:
|
|
239
241
|
return f.read()
|
|
240
242
|
|
|
241
|
-
# Manual wrapping (before)
|
|
242
|
-
try:
|
|
243
|
-
config = read_config("/etc/app.yaml")
|
|
244
|
-
except FileNotFoundError as e:
|
|
245
|
-
raise ErrorChain.wrap(e, as_=StorageError, path=path) from e
|
|
246
|
-
|
|
247
|
-
# Declarative wrapping (after)
|
|
248
|
-
@wrap_errors(StorageError, catch=(FileNotFoundError, IOError))
|
|
249
|
-
def read_config(path: str) -> str:
|
|
250
|
-
with open(path) as f:
|
|
251
|
-
return f.read()
|
|
252
|
-
|
|
253
243
|
try:
|
|
254
244
|
config = read_config("/etc/app.yaml")
|
|
255
245
|
except StorageError as err:
|
|
@@ -258,18 +248,102 @@ except StorageError as err:
|
|
|
258
248
|
pass
|
|
259
249
|
```
|
|
260
250
|
|
|
251
|
+
**Service classes (class-level wrapping):**
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from domain_errors import DomainError, wrap_errors
|
|
255
|
+
|
|
256
|
+
class PaymentError(DomainError):
|
|
257
|
+
code = "payment_error"
|
|
258
|
+
domain = "payment"
|
|
259
|
+
http_status = 402
|
|
260
|
+
|
|
261
|
+
@wrap_errors(PaymentError, catch=(ValueError,))
|
|
262
|
+
class PaymentService:
|
|
263
|
+
def validate_amount(self, amount: int) -> bool:
|
|
264
|
+
if amount <= 0:
|
|
265
|
+
raise ValueError("Amount must be positive")
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
async def process_payment(self, card: str, amount: int) -> str:
|
|
269
|
+
if not card:
|
|
270
|
+
raise ValueError("Card required")
|
|
271
|
+
return f"Processed {amount}"
|
|
272
|
+
|
|
273
|
+
service = PaymentService()
|
|
274
|
+
|
|
275
|
+
assert service.validate_amount(100)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
service.validate_amount(-50)
|
|
279
|
+
except PaymentError as err:
|
|
280
|
+
print(f"Error: {err.message}")
|
|
281
|
+
print(f"Context: {err.context}")
|
|
282
|
+
print(f"Cause: {err.__cause__}")
|
|
283
|
+
```
|
|
284
|
+
|
|
261
285
|
DomainError instances pass through unchanged; works on sync and async callables; set `capture=False` to omit arguments (e.g., for secrets).
|
|
262
286
|
|
|
287
|
+
## Service Class Example
|
|
288
|
+
|
|
289
|
+
A complete service class pattern with error wrapping and context capture:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from domain_errors import DomainError, ErrorChain
|
|
293
|
+
|
|
294
|
+
class PaymentService:
|
|
295
|
+
@staticmethod
|
|
296
|
+
def process_payment(amount_cents: int) -> dict:
|
|
297
|
+
try:
|
|
298
|
+
if amount_cents <= 0:
|
|
299
|
+
raise ValueError("Amount must be positive")
|
|
300
|
+
if amount_cents > 999999:
|
|
301
|
+
raise ValueError("Amount exceeds maximum")
|
|
302
|
+
return {"status": "success", "amount_cents": amount_cents}
|
|
303
|
+
except ValueError as e:
|
|
304
|
+
raise ErrorChain.wrap(
|
|
305
|
+
e,
|
|
306
|
+
as_=PaymentError,
|
|
307
|
+
message=f"Payment validation failed: {e}",
|
|
308
|
+
amount_cents=amount_cents
|
|
309
|
+
) from e
|
|
310
|
+
|
|
311
|
+
class PaymentError(DomainError):
|
|
312
|
+
code = "payment_error"
|
|
313
|
+
domain = "payment"
|
|
314
|
+
http_status = 400
|
|
315
|
+
default_message = "Payment processing failed."
|
|
316
|
+
|
|
317
|
+
# Usage
|
|
318
|
+
try:
|
|
319
|
+
result = PaymentService.process_payment(5000)
|
|
320
|
+
print(f"Success: {result}")
|
|
321
|
+
except PaymentError as err:
|
|
322
|
+
print(f"Error {err.code} in {err.domain}: {err.message}")
|
|
323
|
+
print(f"Context: {err.context}")
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Output:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
✓ Successful payment: {'status': 'success', 'amount_cents': 5000}
|
|
330
|
+
✓ Caught PaymentError: code=payment_error, domain=payment
|
|
331
|
+
Message: Payment validation failed: Amount must be positive
|
|
332
|
+
Context: {'amount_cents': -100}
|
|
333
|
+
Chain length: 2
|
|
334
|
+
✓ Caught PaymentError: amount_cents=9999999
|
|
335
|
+
```
|
|
336
|
+
|
|
263
337
|
## Documentation
|
|
264
338
|
|
|
265
|
-
- **DomainError Base:** [docs/apps/domain_error.md](docs/apps/domain_error.md)
|
|
266
|
-
- **ErrorChain:** [docs/apps/chain.md](docs/apps/chain.md)
|
|
267
|
-
- **@wrap_errors Decorator:** [docs/apps/wrap_errors.md](docs/apps/wrap_errors.md)
|
|
268
|
-
- **Architecture:** [docs/apps/diagrams.md](docs/apps/diagrams.md)
|
|
339
|
+
- **DomainError Base:** [docs/apps/domain_error.md](docs/apps/domain_error.md): class contract, subclassing, message/context semantics
|
|
340
|
+
- **ErrorChain:** [docs/apps/chain.md](docs/apps/chain.md): wrap, history, crossings, logging integration
|
|
341
|
+
- **@wrap_errors Decorator:** [docs/apps/wrap_errors.md](docs/apps/wrap_errors.md): declarative error wrapping for functions and async callables
|
|
342
|
+
- **Architecture:** [docs/apps/diagrams.md](docs/apps/diagrams.md): data flow and domain relationships
|
|
269
343
|
|
|
270
344
|
## License
|
|
271
345
|
|
|
272
|
-
Apache 2.0
|
|
346
|
+
Apache 2.0; see LICENSE file.
|
|
273
347
|
|
|
274
348
|
## Contributing
|
|
275
349
|
|
|
@@ -33,10 +33,10 @@ def wrap(
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
**Parameters:**
|
|
36
|
-
- `err
|
|
37
|
-
- `as_
|
|
38
|
-
- `message
|
|
39
|
-
- `**context
|
|
36
|
+
- `err`: the caught exception to wrap
|
|
37
|
+
- `as_`: the target DomainError subclass
|
|
38
|
+
- `message`: optional custom message (defaults to the class `default_message`)
|
|
39
|
+
- `**context`: structured context data (e.g., `user_id=123, attempt=2`)
|
|
40
40
|
|
|
41
41
|
**Example:**
|
|
42
42
|
```python
|
|
@@ -79,8 +79,8 @@ def history(
|
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
**Parameters:**
|
|
82
|
-
- `err
|
|
83
|
-
- `classifiers
|
|
82
|
+
- `err`: the root exception (typically the caught exception in an except block)
|
|
83
|
+
- `classifiers`: optional domain classifiers to resolve domains for foreign exceptions
|
|
84
84
|
|
|
85
85
|
**Returns:** A tuple of `ChainLink` objects, root exception first, following `__cause__` (CAUSE via) then `__context__` (CONTEXT via) unless suppressed.
|
|
86
86
|
|
|
@@ -119,8 +119,8 @@ def crossings(
|
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
**Parameters:**
|
|
122
|
-
- `err
|
|
123
|
-
- `classifiers
|
|
122
|
+
- `err`: the root exception
|
|
123
|
+
- `classifiers`: optional domain classifiers
|
|
124
124
|
|
|
125
125
|
**Returns:** A tuple of `DomainCrossing` objects, one for each link-to-link transition where domains differ.
|
|
126
126
|
|
|
@@ -172,7 +172,7 @@ class ChainLink:
|
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
**Methods:**
|
|
175
|
-
- `to_log_extra() → dict[str, object]
|
|
175
|
+
- `to_log_extra() → dict[str, object]`: Convert to a JSON-ready dict for logger `extra` parameter.
|
|
176
176
|
|
|
177
177
|
### `DomainCrossing`
|
|
178
178
|
|
|
@@ -186,7 +186,7 @@ class DomainCrossing:
|
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
**Methods:**
|
|
189
|
-
- `to_log_extra() → dict[str, object]
|
|
189
|
+
- `to_log_extra() → dict[str, object]`: Convert to a JSON-ready dict for logger `extra` parameter.
|
|
190
190
|
|
|
191
191
|
### `DomainClassifier` (Protocol)
|
|
192
192
|
|
|
@@ -60,7 +60,7 @@ def classify(self, err: BaseException) -> str | None:
|
|
|
60
60
|
|
|
61
61
|
### Parameters
|
|
62
62
|
|
|
63
|
-
- `err
|
|
63
|
+
- `err`: any caught exception
|
|
64
64
|
|
|
65
65
|
### Returns
|
|
66
66
|
|
|
@@ -159,6 +159,6 @@ except Exception as e:
|
|
|
159
159
|
|
|
160
160
|
## See Also
|
|
161
161
|
|
|
162
|
-
- **ErrorChain:** `docs/apps/chain.md
|
|
163
|
-
- **DomainError Base:** `docs/apps/domain_error.md
|
|
164
|
-
- **Architecture:** `docs/apps/diagrams.md
|
|
162
|
+
- **ErrorChain:** `docs/apps/chain.md`: how to use classifiers with history/crossings
|
|
163
|
+
- **DomainError Base:** `docs/apps/domain_error.md`: how to define domain-specific errors
|
|
164
|
+
- **Architecture:** `docs/apps/diagrams.md`: system diagram with classifier placement
|
|
@@ -175,7 +175,7 @@ domains/http/http_client.py (imports: from __future__ import annotations
|
|
|
175
175
|
│ BY-ORIGIN: maps any exception DEFINED IN an HTTP-client library │
|
|
176
176
|
│ to const.HTTP. No import of the libs (string check on │
|
|
177
177
|
│ type(err).__module__), so NO optional-dep machinery AND complete │
|
|
178
|
-
│ coverage of each lib's whole exception surface
|
|
178
|
+
│ coverage of each lib's whole exception surface: including httpx │
|
|
179
179
|
│ InvalidURL / CookieConflict / StreamError, which sit OUTSIDE the │
|
|
180
180
|
│ httpx.HTTPError tree (a base-class catch would miss them). │
|
|
181
181
|
│ │
|
|
@@ -60,7 +60,7 @@ def classify(self, err: BaseException) -> str | None:
|
|
|
60
60
|
|
|
61
61
|
### Parameters
|
|
62
62
|
|
|
63
|
-
- `err
|
|
63
|
+
- `err`: any caught exception
|
|
64
64
|
|
|
65
65
|
### Returns
|
|
66
66
|
|
|
@@ -153,6 +153,6 @@ except Exception as e:
|
|
|
153
153
|
|
|
154
154
|
## See Also
|
|
155
155
|
|
|
156
|
-
- **ErrorChain:** `docs/apps/chain.md
|
|
157
|
-
- **DomainError Base:** `docs/apps/domain_error.md
|
|
158
|
-
- **Architecture:** `docs/apps/diagrams.md
|
|
156
|
+
- **ErrorChain:** `docs/apps/chain.md`: how to use classifiers with history/crossings
|
|
157
|
+
- **DomainError Base:** `docs/apps/domain_error.md`: how to define domain-specific errors
|
|
158
|
+
- **Architecture:** `docs/apps/diagrams.md`: system diagram with classifier placement
|
|
@@ -51,10 +51,10 @@ _FAMILIES: tuple[tuple[tuple[type[BaseException], ...], str], ...] = (
|
|
|
51
51
|
|
|
52
52
|
The classifier maps to four coarse domains:
|
|
53
53
|
|
|
54
|
-
- **`"network"
|
|
55
|
-
- **`"os"
|
|
56
|
-
- **`"logic"
|
|
57
|
-
- **`"assertion"
|
|
54
|
+
- **`"network"`**: Network-layer exceptions: `ConnectionError`, `TimeoutError`
|
|
55
|
+
- **`"os"`**: Operating system and I/O exceptions: `FileNotFoundError`, `PermissionError`, `OSError` (and subclasses)
|
|
56
|
+
- **`"logic"`**: Logical/programming errors: `ValueError`, `KeyError`, `TypeError`
|
|
57
|
+
- **`"assertion"`**: Assertion failures: `AssertionError`
|
|
58
58
|
|
|
59
59
|
These constants are exported from `domain_errors/domains/constants/python.py`:
|
|
60
60
|
|
|
@@ -71,7 +71,7 @@ def classify(self, err: BaseException) -> str | None:
|
|
|
71
71
|
|
|
72
72
|
### Parameters
|
|
73
73
|
|
|
74
|
-
- `err
|
|
74
|
+
- `err`: any caught exception
|
|
75
75
|
|
|
76
76
|
### Returns
|
|
77
77
|
|
|
@@ -161,6 +161,6 @@ except Exception as e:
|
|
|
161
161
|
|
|
162
162
|
## See Also
|
|
163
163
|
|
|
164
|
-
- **ErrorChain:** `docs/apps/chain.md
|
|
165
|
-
- **DomainError Base:** `docs/apps/domain_error.md
|
|
166
|
-
- **Architecture:** `docs/apps/diagrams.md
|
|
164
|
+
- **ErrorChain:** `docs/apps/chain.md`: how to use classifiers with history/crossings
|
|
165
|
+
- **DomainError Base:** `docs/apps/domain_error.md`: how to define domain-specific errors
|
|
166
|
+
- **Architecture:** `docs/apps/diagrams.md`: system diagram with classifier placement
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
`wrap_errors` is a function decorator that automatically wraps caught exceptions into a target `DomainError` via `ErrorChain.wrap()`, enabling declarative error handling without boilerplate try-except-raise-from blocks. Use it to convert foreign (non-DomainError) exceptions into domain-specific errors, capture function arguments into error context for structured logging, and let `DomainError` instances pass through unchanged.
|
|
11
11
|
|
|
12
|
-
It is the suite-wide error-wrapping **standard** for all OSS packages
|
|
12
|
+
It is the suite-wide error-wrapping **standard** for all OSS packages: the decorator analog of manual `ErrorChain.wrap(...) + raise ... from e`.
|
|
13
13
|
|
|
14
14
|
## Core Design
|
|
15
15
|
|
|
@@ -26,7 +26,7 @@ The decorator:
|
|
|
26
26
|
- Catches exceptions matching the `catch` tuple
|
|
27
27
|
- Calls `ErrorChain.wrap(error, as_=..., message=..., **captured_args)` to construct a domain error
|
|
28
28
|
- Raises the domain error with `raise ... from error` (so `__cause__` is the original exception)
|
|
29
|
-
- **Passes `DomainError` (and subclasses) through unchanged
|
|
29
|
+
- **Passes `DomainError` (and subclasses) through unchanged**: never re-wraps
|
|
30
30
|
- Captures the call's bound arguments (with defaults applied) into the domain error's `.context`, unless disabled with `capture=False`
|
|
31
31
|
- Works transparently on async functions (awaits the coroutine)
|
|
32
32
|
- Preserves the original function's `__name__`, `__doc__`, and signature via `functools.wraps`
|
|
@@ -61,10 +61,10 @@ The returned `WrapErrorsClient` instance is callable; when applied to a function
|
|
|
61
61
|
|
|
62
62
|
## Parameters
|
|
63
63
|
|
|
64
|
-
- **`as_
|
|
65
|
-
- **`catch
|
|
66
|
-
- **`message
|
|
67
|
-
- **`capture
|
|
64
|
+
- **`as_`**: the target `DomainError` subclass to wrap into (required, positional). Exceptions matching `catch` will be wrapped into this type via `ErrorChain.wrap()`.
|
|
65
|
+
- **`catch`**: tuple of `Exception` types to catch (default `(Exception,)`). Only exceptions in this tuple (or subclasses) are wrapped; others propagate raw. Typed as `tuple[type[Exception], ...]`. Note: `BaseException` (e.g., `KeyboardInterrupt`, `SystemExit`) is intentionally NOT catchable.
|
|
66
|
+
- **`message`**: optional override message passed to the constructed `DomainError` (default `None`). If provided, used instead of `DomainError.default_message`.
|
|
67
|
+
- **`capture`**: when `True` (default), the decorated call's bound arguments are captured into the `DomainError`'s `.context` dict via `inspect.signature(func).bind(...) + apply_defaults()`. Set `False` to omit arguments (e.g., for functions handling secrets).
|
|
68
68
|
|
|
69
69
|
## Behavior
|
|
70
70
|
|
|
@@ -96,7 +96,7 @@ def authenticate(username: str, password: str) -> str:
|
|
|
96
96
|
Guidance:
|
|
97
97
|
- Default to `capture=False` on authentication, payment, and PII-handling functions.
|
|
98
98
|
- Argument capture is all-or-nothing in v0.1.0; field-level redaction (e.g. via a sensitivity-mixin integration) is a planned future enhancement.
|
|
99
|
-
- Captured context is emitted verbatim
|
|
99
|
+
- Captured context is emitted verbatim: redaction is the consumer's logging-pipeline responsibility until field-level support ships.
|
|
100
100
|
|
|
101
101
|
## Examples
|
|
102
102
|
|
|
@@ -245,6 +245,59 @@ except AuthError as err:
|
|
|
245
245
|
# Context: {} (no args captured)
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
+
### Example 6: Class-level decoration (fans out to all public methods)
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from domain_errors import DomainError, wrap_errors
|
|
252
|
+
import asyncio
|
|
253
|
+
|
|
254
|
+
class PaymentError(DomainError):
|
|
255
|
+
code = "payment_error"
|
|
256
|
+
domain = "payment"
|
|
257
|
+
http_status = 402
|
|
258
|
+
|
|
259
|
+
@wrap_errors(PaymentError, catch=(ValueError, IOError))
|
|
260
|
+
class PaymentService:
|
|
261
|
+
def __init__(self, rate_limit: int = 100) -> None:
|
|
262
|
+
self.rate_limit = rate_limit
|
|
263
|
+
|
|
264
|
+
def validate_amount(self, amount: int) -> bool:
|
|
265
|
+
if amount <= 0:
|
|
266
|
+
raise ValueError("Amount must be positive")
|
|
267
|
+
if amount > self.rate_limit:
|
|
268
|
+
raise ValueError("Amount exceeds rate limit")
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
async def charge_card(self, card_token: str, amount: int) -> str:
|
|
272
|
+
if not card_token:
|
|
273
|
+
raise ValueError("Card token required")
|
|
274
|
+
return f"Charged {amount} on {card_token}"
|
|
275
|
+
|
|
276
|
+
service = PaymentService(rate_limit=5000)
|
|
277
|
+
|
|
278
|
+
assert service.validate_amount(100)
|
|
279
|
+
|
|
280
|
+
with pytest.raises(PaymentError) as exc_info:
|
|
281
|
+
service.validate_amount(-50)
|
|
282
|
+
assert exc_info.value.__cause__.__class__ == ValueError
|
|
283
|
+
assert exc_info.value.context == {"amount": -50}
|
|
284
|
+
|
|
285
|
+
result = asyncio.run(service.charge_card("tok_visa", 1000))
|
|
286
|
+
assert "Charged 1000" in result
|
|
287
|
+
|
|
288
|
+
with pytest.raises(PaymentError):
|
|
289
|
+
asyncio.run(service.charge_card("", 1000))
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Class-level behavior:**
|
|
293
|
+
- Decorating a class fans out the decorator over all public callables in `cls.__dict__`.
|
|
294
|
+
- Sync methods get the sync wrapper; async methods get the async wrapper (dispatch preserved per method).
|
|
295
|
+
- Private methods (prefixed with `_`), dunder methods (e.g., `__init__`), properties, and nested classes are skipped.
|
|
296
|
+
- Classmethod and staticmethod are unwrapped, decorated, and rewrapped to preserve their semantics.
|
|
297
|
+
- Methods already decorated with `@wrap_errors` (marked with `__wrap_errors_applied__`) are left untouched (override detection).
|
|
298
|
+
- The `capture`, `message`, and `catch` parameters apply uniformly to all fanned methods.
|
|
299
|
+
- Arguments `self` and `cls` are automatically filtered from the error context to avoid object serialization issues.
|
|
300
|
+
|
|
248
301
|
## Usage with ErrorChain
|
|
249
302
|
|
|
250
303
|
After the decorator wraps an error, walk the chain to classify foreign exceptions and log structured data:
|
|
@@ -294,6 +347,6 @@ except ServiceError as err:
|
|
|
294
347
|
|
|
295
348
|
## See Also
|
|
296
349
|
|
|
297
|
-
- **ErrorChain:** `docs/apps/chain.md
|
|
298
|
-
- **DomainError Base:** `docs/apps/domain_error.md
|
|
299
|
-
- **Architecture:** `docs/apps/diagrams.md
|
|
350
|
+
- **ErrorChain:** `docs/apps/chain.md`: how to walk chains and use classifiers
|
|
351
|
+
- **DomainError Base:** `docs/apps/domain_error.md`: how to define domain-specific errors
|
|
352
|
+
- **Architecture:** `docs/apps/diagrams.md`: system diagram with decorator placement
|