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.
@@ -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).
@@ -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.