retryguard 1.0.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.
- retryguard-1.0.0/.github/workflows/ci.yml +56 -0
- retryguard-1.0.0/.github/workflows/release.yml +33 -0
- retryguard-1.0.0/.gitignore +41 -0
- retryguard-1.0.0/CHANGELOG.md +60 -0
- retryguard-1.0.0/LICENSE +21 -0
- retryguard-1.0.0/PKG-INFO +245 -0
- retryguard-1.0.0/README.md +187 -0
- retryguard-1.0.0/pyproject.toml +61 -0
- retryguard-1.0.0/src/retryguard/__init__.py +11 -0
- retryguard-1.0.0/src/retryguard/classifier.py +68 -0
- retryguard-1.0.0/src/retryguard/integrations/__init__.py +1 -0
- retryguard-1.0.0/src/retryguard/integrations/celery.py +21 -0
- retryguard-1.0.0/src/retryguard/integrations/tenacity.py +73 -0
- retryguard-1.0.0/src/retryguard/models.py +26 -0
- retryguard-1.0.0/src/retryguard/parsers.py +118 -0
- retryguard-1.0.0/src/retryguard/py.typed +1 -0
- retryguard-1.0.0/src/retryguard/rules.py +339 -0
- retryguard-1.0.0/tests/conftest.py +7 -0
- retryguard-1.0.0/tests/test_classifier.py +109 -0
- retryguard-1.0.0/tests/test_classifier_advanced.py +189 -0
- retryguard-1.0.0/tests/test_end_to_end.py +471 -0
- retryguard-1.0.0/tests/test_integrations_celery.py +83 -0
- retryguard-1.0.0/tests/test_integrations_tenacity.py +158 -0
- retryguard-1.0.0/tests/test_parsers.py +370 -0
- retryguard-1.0.0/tests/test_rules.py +354 -0
- retryguard-1.0.0/tests/test_rules_db.py +196 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.11", "3.12"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
cache: pip
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: pip install -e ".[http,db,retry,dev]"
|
|
28
|
+
|
|
29
|
+
- name: Run tests with coverage
|
|
30
|
+
run: |
|
|
31
|
+
pytest --cov=retryguard --cov-report=term-missing --cov-report=xml -v
|
|
32
|
+
|
|
33
|
+
- name: Upload coverage report
|
|
34
|
+
if: matrix.python-version == '3.12'
|
|
35
|
+
uses: actions/upload-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: coverage-xml
|
|
38
|
+
path: coverage.xml
|
|
39
|
+
|
|
40
|
+
lint:
|
|
41
|
+
name: Lint
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
|
|
47
|
+
- uses: actions/setup-python@v5
|
|
48
|
+
with:
|
|
49
|
+
python-version: "3.12"
|
|
50
|
+
cache: pip
|
|
51
|
+
|
|
52
|
+
- name: Install ruff
|
|
53
|
+
run: pip install "ruff>=0.6.0"
|
|
54
|
+
|
|
55
|
+
- name: Run ruff
|
|
56
|
+
run: ruff check src tests
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
pypi:
|
|
11
|
+
name: Publish to PyPI
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
cache: pip
|
|
25
|
+
|
|
26
|
+
- name: Build distributions
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
python -m pip install build
|
|
30
|
+
python -m build
|
|
31
|
+
|
|
32
|
+
- name: Publish to PyPI
|
|
33
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyd
|
|
5
|
+
*.pyo
|
|
6
|
+
|
|
7
|
+
# Distribution / packaging
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
.eggs/
|
|
13
|
+
|
|
14
|
+
# Virtual environments
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
env/
|
|
18
|
+
.env
|
|
19
|
+
|
|
20
|
+
# Testing
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
pytest-cache-files-*
|
|
23
|
+
.coverage
|
|
24
|
+
coverage.xml
|
|
25
|
+
htmlcov/
|
|
26
|
+
|
|
27
|
+
# Type checkers
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.pyright/
|
|
30
|
+
|
|
31
|
+
# Linters / formatters
|
|
32
|
+
.ruff_cache/
|
|
33
|
+
|
|
34
|
+
# Editor
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
*.iml
|
|
38
|
+
|
|
39
|
+
# OS
|
|
40
|
+
.DS_Store
|
|
41
|
+
Thumbs.db
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## [1.0.0] — 2026-04-29
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `classify_requests`: `requests.Timeout` now correctly returns `TIMEOUT` category
|
|
16
|
+
instead of `NETWORK`. `requests.ConnectTimeout` (subclass of both `Timeout` and
|
|
17
|
+
`ConnectionError`) also gets `TIMEOUT` because `Timeout` is checked first.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Postgres SQLSTATE class `53` (insufficient resources: disk_full, out_of_memory,
|
|
21
|
+
configuration_limit_exceeded) and class `58` (system errors: io_error,
|
|
22
|
+
undefined_file) are now retryable with `DATABASE` category.
|
|
23
|
+
- `ErrorClassifier.classify()` now wraps each rule call in `try/except`; a crashing
|
|
24
|
+
rule is skipped and the next rule continues. Previously a faulty third-party rule
|
|
25
|
+
would propagate an unhandled exception to the caller.
|
|
26
|
+
- `retry_if_retryguard()`, `wait_retryguard()`, and `before_sleep_log_retryguard()`
|
|
27
|
+
now call `default_classifier()` (the module-level singleton) when no explicit
|
|
28
|
+
classifier is passed, instead of each creating a separate `ErrorClassifier()`.
|
|
29
|
+
- SQLAlchemy rule: `DBAPIError` without SQLSTATE now returns a non-retryable
|
|
30
|
+
`DATABASE` decision (`reason_code="sqlalchemy_unclassified_dbapi_error"`) instead
|
|
31
|
+
of falling through to `None`.
|
|
32
|
+
- GitHub Actions: CI now installs the `db` extra; release workflow added for PyPI.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- Optional dependency version specifiers now include upper bounds:
|
|
36
|
+
`httpx<2.0`, `requests<3.0`, `SQLAlchemy<3.0`, `asyncpg<1.0`,
|
|
37
|
+
`psycopg<4.0`, `tenacity<10.0`.
|
|
38
|
+
- Documented the stable API surface for 1.0+.
|
|
39
|
+
- Removed the Postgres string-marker fallback; classification is SQLSTATE/type-based only.
|
|
40
|
+
|
|
41
|
+
## [0.1.0] — 2026-04-29
|
|
42
|
+
|
|
43
|
+
Initial release.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- `ErrorClassifier` with configurable rule pipeline.
|
|
47
|
+
- `RetryDecision` frozen dataclass: `retryable`, `category`, `reason_code`,
|
|
48
|
+
`reason`, `retry_after_seconds`, `suggested_delay_seconds`.
|
|
49
|
+
- `RetryCategory` enum: `NETWORK`, `TIMEOUT`, `RATE_LIMIT`, `SERVER`, `CLIENT`,
|
|
50
|
+
`AUTH`, `VALIDATION`, `DATABASE`, `UNKNOWN`.
|
|
51
|
+
- Built-in rules covering Python builtins, HTTP status codes, httpx, requests,
|
|
52
|
+
SQLAlchemy, psycopg3, asyncpg, and Postgres SQLSTATE / string fallback.
|
|
53
|
+
- `Retry-After` header parsing (delta-seconds and HTTP-date forms).
|
|
54
|
+
- Exception chain traversal for wrapped DBAPI errors (`.orig`, `__cause__`,
|
|
55
|
+
`__context__`, `.diag.sqlstate`).
|
|
56
|
+
- Celery integration: `countdown_from_decision()`.
|
|
57
|
+
- Tenacity integration: `retry_if_retryguard()`, `wait_retryguard()`,
|
|
58
|
+
`before_sleep_log_retryguard()`.
|
|
59
|
+
- `classify_error()` and `should_retry()` convenience helpers.
|
|
60
|
+
- `py.typed` marker (PEP 561).
|
retryguard-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 retryguard contributors
|
|
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.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: retryguard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Retryable and non-retryable error classifier for Python backends
|
|
5
|
+
Project-URL: Homepage, https://github.com/rnx2024/retryguard
|
|
6
|
+
Project-URL: Repository, https://github.com/rnx2024/retryguard
|
|
7
|
+
Project-URL: Issues, https://github.com/rnx2024/retryguard/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/rnx2024/retryguard/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: PyPI, https://pypi.org/project/retryguard/
|
|
10
|
+
Author-email: Rhanny Urbis <raniurbis@gmail.com>
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2026 retryguard contributors
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Keywords: celery,error classification,httpx,postgres,resilience,retry,sqlalchemy,tenacity
|
|
34
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.11
|
|
43
|
+
Provides-Extra: db
|
|
44
|
+
Requires-Dist: asyncpg<1.0,>=0.29.0; extra == 'db'
|
|
45
|
+
Requires-Dist: psycopg<4.0,>=3.1.0; extra == 'db'
|
|
46
|
+
Requires-Dist: sqlalchemy<3.0,>=2.0.0; extra == 'db'
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: coverage[toml]>=7.0.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
52
|
+
Provides-Extra: http
|
|
53
|
+
Requires-Dist: httpx<2.0,>=0.27.0; extra == 'http'
|
|
54
|
+
Requires-Dist: requests<3.0,>=2.32.0; extra == 'http'
|
|
55
|
+
Provides-Extra: retry
|
|
56
|
+
Requires-Dist: tenacity<10.0,>=8.2.0; extra == 'retry'
|
|
57
|
+
Description-Content-Type: text/markdown
|
|
58
|
+
|
|
59
|
+
# retryguard
|
|
60
|
+
|
|
61
|
+
Small, dependency-light library that classifies exceptions into **retryable** vs **non-retryable** decisions.
|
|
62
|
+
|
|
63
|
+
Goal: make every backend service call *one* classifier before retrying anything, so you stop blindly retrying
|
|
64
|
+
things like `400`, `401`, validation failures, or malformed payloads.
|
|
65
|
+
|
|
66
|
+
## What it returns
|
|
67
|
+
|
|
68
|
+
For any exception, `retryguard` returns a `RetryDecision`:
|
|
69
|
+
|
|
70
|
+
- `retryable: bool`
|
|
71
|
+
- `reason: str`
|
|
72
|
+
- `reason_code: str` (stable, machine-usable)
|
|
73
|
+
- `category: RetryCategory`
|
|
74
|
+
- `retry_after_seconds: float | None`
|
|
75
|
+
- `suggested_delay_seconds: float | None`
|
|
76
|
+
|
|
77
|
+
## Capabilities
|
|
78
|
+
|
|
79
|
+
`retryguard` is a **policy classifier**: it inspects exceptions (types and attributes) and returns a
|
|
80
|
+
`RetryDecision`. It does **not** perform any I/O and it does **not** implement retry loops/backoff
|
|
81
|
+
itself.
|
|
82
|
+
|
|
83
|
+
It classifies:
|
|
84
|
+
|
|
85
|
+
- **HTTP status codes**: common retryable/non-retryable codes, including `Retry-After` support for `429`.
|
|
86
|
+
- **httpx / requests** (when installed): timeout vs network exception types.
|
|
87
|
+
- **Postgres via SQLSTATE** (when available): extracts `sqlstate`/`pgcode` (including wrapped exceptions)
|
|
88
|
+
and maps transient/non-transient codes to decisions.
|
|
89
|
+
- **SQLAlchemy** (when installed): pool timeouts and `DBAPIError.connection_invalidated`, plus SQLSTATE
|
|
90
|
+
extraction from the wrapped DBAPI exception chain.
|
|
91
|
+
- **Builtins**: `TimeoutError` (retryable), `ConnectionError`/`OSError` (retryable), `ValueError`
|
|
92
|
+
(non-retryable).
|
|
93
|
+
|
|
94
|
+
Unknowns default to **non-retryable**.
|
|
95
|
+
|
|
96
|
+
## Default policy (Phase 1)
|
|
97
|
+
|
|
98
|
+
Retryable by default:
|
|
99
|
+
|
|
100
|
+
- timeouts, connection resets, DNS/network blips
|
|
101
|
+
- rate limits
|
|
102
|
+
- HTTP `408, 425, 429, 500, 502, 503, 504`
|
|
103
|
+
- Postgres transient SQLSTATEs (works via `sqlstate/pgcode` extraction; supports wrappers like SQLAlchemy)
|
|
104
|
+
|
|
105
|
+
Non-retryable by default:
|
|
106
|
+
|
|
107
|
+
- validation/parsing errors
|
|
108
|
+
- bad credentials / auth failures
|
|
109
|
+
- HTTP `400, 401, 403, 404, 405, 409, 410, 422`
|
|
110
|
+
|
|
111
|
+
Unknowns default to **non-retryable**
|
|
112
|
+
|
|
113
|
+
## Postgres / SQLAlchemy / asyncpg / psycopg3
|
|
114
|
+
|
|
115
|
+
`retryguard` classifies Postgres errors primarily via SQLSTATE, including when wrapped by
|
|
116
|
+
SQLAlchemy (`.orig`, `__cause__`, `__context__` are unwrapped).
|
|
117
|
+
|
|
118
|
+
If a SQLAlchemy `DBAPIError` has no SQLSTATE anywhere in its exception chain and doesn't match the
|
|
119
|
+
SQLSTATE-based rules, `retryguard` returns a non-retryable `DATABASE` decision with
|
|
120
|
+
`reason_code="sqlalchemy_unclassified_dbapi_error"` (instead of falling through and potentially ending up
|
|
121
|
+
as `UNKNOWN`).
|
|
122
|
+
|
|
123
|
+
Retryable examples:
|
|
124
|
+
|
|
125
|
+
- `08xxx` connection exceptions
|
|
126
|
+
- `40001` serialization failure
|
|
127
|
+
- `40P01` deadlock detected
|
|
128
|
+
- `55P03` lock not available
|
|
129
|
+
- `53xxx` insufficient resources (too many connections, disk full, out of memory)
|
|
130
|
+
- `57014` query canceled (often statement timeout)
|
|
131
|
+
- `57P01/57P02/57P03` shutdown / cannot connect now
|
|
132
|
+
- `58xxx` system errors (I/O error, undefined file)
|
|
133
|
+
|
|
134
|
+
Non-retryable examples:
|
|
135
|
+
|
|
136
|
+
- `23xxx` constraint violations (e.g. `23505` unique violation)
|
|
137
|
+
- `28xxx` invalid authorization
|
|
138
|
+
- `22xxx` data exceptions (invalid input, etc.)
|
|
139
|
+
|
|
140
|
+
## Usage
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from retryguard import ErrorClassifier
|
|
144
|
+
|
|
145
|
+
classifier = ErrorClassifier()
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
...
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
decision = classifier.classify(exc)
|
|
151
|
+
if decision.retryable:
|
|
152
|
+
delay = decision.retry_after_seconds or decision.suggested_delay_seconds or 2.0
|
|
153
|
+
print("retry", delay, decision.reason_code, decision.reason)
|
|
154
|
+
else:
|
|
155
|
+
print("fail", decision.reason_code, decision.reason)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Celery example (don’t retry blindly)
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from celery import shared_task
|
|
162
|
+
from retryguard import ErrorClassifier
|
|
163
|
+
from retryguard.integrations.celery import countdown_from_decision
|
|
164
|
+
|
|
165
|
+
classifier = ErrorClassifier()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@shared_task(bind=True, max_retries=5)
|
|
169
|
+
def run_job(self, payload: dict) -> str:
|
|
170
|
+
try:
|
|
171
|
+
return do_work(payload)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
decision = classifier.classify(exc)
|
|
174
|
+
if not decision.retryable:
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
delay = countdown_from_decision(decision, default_seconds=2)
|
|
178
|
+
raise self.retry(exc=exc, countdown=delay)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Tenacity (build on top, don’t reimplement)
|
|
182
|
+
|
|
183
|
+
Tenacity handles *how* to retry (stop/backoff/jitter). `retryguard` decides *whether* to retry.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import logging
|
|
187
|
+
from tenacity import retry, stop_after_attempt
|
|
188
|
+
from retryguard import ErrorClassifier
|
|
189
|
+
from retryguard.integrations.tenacity import (
|
|
190
|
+
before_sleep_log_retryguard,
|
|
191
|
+
retry_if_retryguard,
|
|
192
|
+
wait_retryguard,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
logger = logging.getLogger(__name__)
|
|
196
|
+
classifier = ErrorClassifier()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@retry(
|
|
200
|
+
retry=retry_if_retryguard(classifier),
|
|
201
|
+
wait=wait_retryguard(classifier, fallback_seconds=1.0),
|
|
202
|
+
stop=stop_after_attempt(5),
|
|
203
|
+
before_sleep=before_sleep_log_retryguard(logger, classifier=classifier),
|
|
204
|
+
)
|
|
205
|
+
def call_something():
|
|
206
|
+
...
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Thread and async safety
|
|
210
|
+
|
|
211
|
+
`ErrorClassifier` is stateless — it holds only an immutable tuple of rule functions and
|
|
212
|
+
creates no shared mutable state during `classify()`. It is safe to share a single
|
|
213
|
+
instance across threads and async tasks.
|
|
214
|
+
|
|
215
|
+
The module-level singleton from `default_classifier()` is cached via `@lru_cache`, which
|
|
216
|
+
is thread-safe in CPython. For async code (asyncio, trio), the classifier itself is safe
|
|
217
|
+
to call from any coroutine; no I/O is performed.
|
|
218
|
+
|
|
219
|
+
## Overrides
|
|
220
|
+
|
|
221
|
+
Put provider-specific logic in a custom rule and pass it before the defaults:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from retryguard import ErrorClassifier, RetryDecision, RetryCategory
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def classify_my_service(exc: BaseException) -> RetryDecision | None:
|
|
228
|
+
...
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
classifier = ErrorClassifier(rules=(classify_my_service, *ErrorClassifier.DEFAULT_RULES))
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Stable API (1.0+)
|
|
235
|
+
|
|
236
|
+
The stable surface is the public package API:
|
|
237
|
+
|
|
238
|
+
- `retryguard.ErrorClassifier`
|
|
239
|
+
- `retryguard.RetryDecision` and `retryguard.RetryCategory`
|
|
240
|
+
- `retryguard.classify_error()`, `retryguard.should_retry()`, `retryguard.default_classifier()`
|
|
241
|
+
- `retryguard.integrations.celery.countdown_from_decision()`
|
|
242
|
+
- `retryguard.integrations.tenacity.retry_if_retryguard()`, `wait_retryguard()`, `before_sleep_log_retryguard()`
|
|
243
|
+
|
|
244
|
+
Everything else (including `retryguard.rules.*` and `retryguard.parsers.*`) is considered internal and may
|
|
245
|
+
change without notice, even in minor versions.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# retryguard
|
|
2
|
+
|
|
3
|
+
Small, dependency-light library that classifies exceptions into **retryable** vs **non-retryable** decisions.
|
|
4
|
+
|
|
5
|
+
Goal: make every backend service call *one* classifier before retrying anything, so you stop blindly retrying
|
|
6
|
+
things like `400`, `401`, validation failures, or malformed payloads.
|
|
7
|
+
|
|
8
|
+
## What it returns
|
|
9
|
+
|
|
10
|
+
For any exception, `retryguard` returns a `RetryDecision`:
|
|
11
|
+
|
|
12
|
+
- `retryable: bool`
|
|
13
|
+
- `reason: str`
|
|
14
|
+
- `reason_code: str` (stable, machine-usable)
|
|
15
|
+
- `category: RetryCategory`
|
|
16
|
+
- `retry_after_seconds: float | None`
|
|
17
|
+
- `suggested_delay_seconds: float | None`
|
|
18
|
+
|
|
19
|
+
## Capabilities
|
|
20
|
+
|
|
21
|
+
`retryguard` is a **policy classifier**: it inspects exceptions (types and attributes) and returns a
|
|
22
|
+
`RetryDecision`. It does **not** perform any I/O and it does **not** implement retry loops/backoff
|
|
23
|
+
itself.
|
|
24
|
+
|
|
25
|
+
It classifies:
|
|
26
|
+
|
|
27
|
+
- **HTTP status codes**: common retryable/non-retryable codes, including `Retry-After` support for `429`.
|
|
28
|
+
- **httpx / requests** (when installed): timeout vs network exception types.
|
|
29
|
+
- **Postgres via SQLSTATE** (when available): extracts `sqlstate`/`pgcode` (including wrapped exceptions)
|
|
30
|
+
and maps transient/non-transient codes to decisions.
|
|
31
|
+
- **SQLAlchemy** (when installed): pool timeouts and `DBAPIError.connection_invalidated`, plus SQLSTATE
|
|
32
|
+
extraction from the wrapped DBAPI exception chain.
|
|
33
|
+
- **Builtins**: `TimeoutError` (retryable), `ConnectionError`/`OSError` (retryable), `ValueError`
|
|
34
|
+
(non-retryable).
|
|
35
|
+
|
|
36
|
+
Unknowns default to **non-retryable**.
|
|
37
|
+
|
|
38
|
+
## Default policy (Phase 1)
|
|
39
|
+
|
|
40
|
+
Retryable by default:
|
|
41
|
+
|
|
42
|
+
- timeouts, connection resets, DNS/network blips
|
|
43
|
+
- rate limits
|
|
44
|
+
- HTTP `408, 425, 429, 500, 502, 503, 504`
|
|
45
|
+
- Postgres transient SQLSTATEs (works via `sqlstate/pgcode` extraction; supports wrappers like SQLAlchemy)
|
|
46
|
+
|
|
47
|
+
Non-retryable by default:
|
|
48
|
+
|
|
49
|
+
- validation/parsing errors
|
|
50
|
+
- bad credentials / auth failures
|
|
51
|
+
- HTTP `400, 401, 403, 404, 405, 409, 410, 422`
|
|
52
|
+
|
|
53
|
+
Unknowns default to **non-retryable**
|
|
54
|
+
|
|
55
|
+
## Postgres / SQLAlchemy / asyncpg / psycopg3
|
|
56
|
+
|
|
57
|
+
`retryguard` classifies Postgres errors primarily via SQLSTATE, including when wrapped by
|
|
58
|
+
SQLAlchemy (`.orig`, `__cause__`, `__context__` are unwrapped).
|
|
59
|
+
|
|
60
|
+
If a SQLAlchemy `DBAPIError` has no SQLSTATE anywhere in its exception chain and doesn't match the
|
|
61
|
+
SQLSTATE-based rules, `retryguard` returns a non-retryable `DATABASE` decision with
|
|
62
|
+
`reason_code="sqlalchemy_unclassified_dbapi_error"` (instead of falling through and potentially ending up
|
|
63
|
+
as `UNKNOWN`).
|
|
64
|
+
|
|
65
|
+
Retryable examples:
|
|
66
|
+
|
|
67
|
+
- `08xxx` connection exceptions
|
|
68
|
+
- `40001` serialization failure
|
|
69
|
+
- `40P01` deadlock detected
|
|
70
|
+
- `55P03` lock not available
|
|
71
|
+
- `53xxx` insufficient resources (too many connections, disk full, out of memory)
|
|
72
|
+
- `57014` query canceled (often statement timeout)
|
|
73
|
+
- `57P01/57P02/57P03` shutdown / cannot connect now
|
|
74
|
+
- `58xxx` system errors (I/O error, undefined file)
|
|
75
|
+
|
|
76
|
+
Non-retryable examples:
|
|
77
|
+
|
|
78
|
+
- `23xxx` constraint violations (e.g. `23505` unique violation)
|
|
79
|
+
- `28xxx` invalid authorization
|
|
80
|
+
- `22xxx` data exceptions (invalid input, etc.)
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from retryguard import ErrorClassifier
|
|
86
|
+
|
|
87
|
+
classifier = ErrorClassifier()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
...
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
decision = classifier.classify(exc)
|
|
93
|
+
if decision.retryable:
|
|
94
|
+
delay = decision.retry_after_seconds or decision.suggested_delay_seconds or 2.0
|
|
95
|
+
print("retry", delay, decision.reason_code, decision.reason)
|
|
96
|
+
else:
|
|
97
|
+
print("fail", decision.reason_code, decision.reason)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Celery example (don’t retry blindly)
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from celery import shared_task
|
|
104
|
+
from retryguard import ErrorClassifier
|
|
105
|
+
from retryguard.integrations.celery import countdown_from_decision
|
|
106
|
+
|
|
107
|
+
classifier = ErrorClassifier()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@shared_task(bind=True, max_retries=5)
|
|
111
|
+
def run_job(self, payload: dict) -> str:
|
|
112
|
+
try:
|
|
113
|
+
return do_work(payload)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
decision = classifier.classify(exc)
|
|
116
|
+
if not decision.retryable:
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
delay = countdown_from_decision(decision, default_seconds=2)
|
|
120
|
+
raise self.retry(exc=exc, countdown=delay)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Tenacity (build on top, don’t reimplement)
|
|
124
|
+
|
|
125
|
+
Tenacity handles *how* to retry (stop/backoff/jitter). `retryguard` decides *whether* to retry.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import logging
|
|
129
|
+
from tenacity import retry, stop_after_attempt
|
|
130
|
+
from retryguard import ErrorClassifier
|
|
131
|
+
from retryguard.integrations.tenacity import (
|
|
132
|
+
before_sleep_log_retryguard,
|
|
133
|
+
retry_if_retryguard,
|
|
134
|
+
wait_retryguard,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger = logging.getLogger(__name__)
|
|
138
|
+
classifier = ErrorClassifier()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@retry(
|
|
142
|
+
retry=retry_if_retryguard(classifier),
|
|
143
|
+
wait=wait_retryguard(classifier, fallback_seconds=1.0),
|
|
144
|
+
stop=stop_after_attempt(5),
|
|
145
|
+
before_sleep=before_sleep_log_retryguard(logger, classifier=classifier),
|
|
146
|
+
)
|
|
147
|
+
def call_something():
|
|
148
|
+
...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Thread and async safety
|
|
152
|
+
|
|
153
|
+
`ErrorClassifier` is stateless — it holds only an immutable tuple of rule functions and
|
|
154
|
+
creates no shared mutable state during `classify()`. It is safe to share a single
|
|
155
|
+
instance across threads and async tasks.
|
|
156
|
+
|
|
157
|
+
The module-level singleton from `default_classifier()` is cached via `@lru_cache`, which
|
|
158
|
+
is thread-safe in CPython. For async code (asyncio, trio), the classifier itself is safe
|
|
159
|
+
to call from any coroutine; no I/O is performed.
|
|
160
|
+
|
|
161
|
+
## Overrides
|
|
162
|
+
|
|
163
|
+
Put provider-specific logic in a custom rule and pass it before the defaults:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from retryguard import ErrorClassifier, RetryDecision, RetryCategory
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def classify_my_service(exc: BaseException) -> RetryDecision | None:
|
|
170
|
+
...
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
classifier = ErrorClassifier(rules=(classify_my_service, *ErrorClassifier.DEFAULT_RULES))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Stable API (1.0+)
|
|
177
|
+
|
|
178
|
+
The stable surface is the public package API:
|
|
179
|
+
|
|
180
|
+
- `retryguard.ErrorClassifier`
|
|
181
|
+
- `retryguard.RetryDecision` and `retryguard.RetryCategory`
|
|
182
|
+
- `retryguard.classify_error()`, `retryguard.should_retry()`, `retryguard.default_classifier()`
|
|
183
|
+
- `retryguard.integrations.celery.countdown_from_decision()`
|
|
184
|
+
- `retryguard.integrations.tenacity.retry_if_retryguard()`, `wait_retryguard()`, `before_sleep_log_retryguard()`
|
|
185
|
+
|
|
186
|
+
Everything else (including `retryguard.rules.*` and `retryguard.parsers.*`) is considered internal and may
|
|
187
|
+
change without notice, even in minor versions.
|