Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
(`ResponseError` subclasses) edge cases explicitly so credential failures and
transient cluster-resharding states aren't misclassified.
- New `redis` optional dependency group (`redis>=4.2.0,<6.0`).
- `classify_aws` rule: `Error.Code`-based classification of `botocore.exceptions.ClientError`
(mirroring botocore's own internal retry policy from `botocore.retries.standard`, not an
invented list), plus type-based dispatch for connection-level `BotoCoreError`s. Deliberately
does not reuse `classify_http_status`, because AWS returns HTTP `400` for both throttling and
genuine permanent validation errors — status-code-only classification would misclassify
throttling as non-retryable. Registered in `DEFAULT_RULES` before `classify_builtin`, since
`botocore.exceptions.ConnectTimeoutError`/`ReadTimeoutError`/`ProxyConnectionError` subclass
builtin `OSError` and would otherwise be intercepted there.
- New `aws` optional dependency group (`botocore>=1.34.0,<2.0`).
- `classify_gcp` rule: type-based classification of `google.api_core.exceptions.*`.
Registered in `DEFAULT_RULES` *before* `classify_http_status` — google-api-core
exceptions expose a `.code` attribute that the generic HTTP-status extraction
already reads, so `classify_http_status` would otherwise intercept every GCP
exception first. Fixes two cases where GCP's own semantics diverge from generic
HTTP status-code conventions: `Aborted` (HTTP 409, but retryable — transaction
conflict, same precedent as Postgres `40001`/Redis `WatchError`) and
`DeadlineExceeded`/`GatewayTimeout`/`BadGateway` (HTTP 504/502, but not
retryable by google-api-core's own default retry policy). Every other case is
classified explicitly with its own `gcp_*` reason code rather than delegating to
the generic HTTP rule, to avoid GCP's correctness silently depending on
`classify_http_status`'s status-code tables never changing.
- New `gcp` optional dependency group (`google-api-core>=2.0.0,<3.0`).
- `classify_azure` rule: type/status-based classification of `azure.core.exceptions.*`.
Registered in `DEFAULT_RULES` *before* `classify_http_status` — `HttpResponseError`
exposes a `.status_code` attribute (the first name the generic HTTP-status
extraction checks), so `classify_http_status` would otherwise intercept every
Azure exception first. `ResourceModifiedError` (ETag conflict, typically HTTP
412) is treated as retryable — same precedent as Postgres `40001`, Redis
`WatchError`, AWS `ConditionalCheckFailedException`, and GCP `Aborted` — and is
disambiguated by type from `ResourceNotFoundError`, which can carry the same
412 status on update operations but stays non-retryable.
- New `azure` optional dependency group (`azure-core>=1.28.0,<2.0`).

---

Expand Down
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ It classifies:
extraction from the wrapped DBAPI exception chain.
- **Redis / redis-py** (when installed): type-based dispatch over `redis.exceptions.*` — no message
parsing, since redis-py already parses the wire-protocol error into distinct exception classes.
- **AWS SDK / botocore** (when installed): `Error.Code`-based classification for `ClientError`
(mirroring botocore's own internal retry policy), plus type-based dispatch for connection-level
`BotoCoreError`s.
- **Google Cloud / google-api-core** (when installed): type-based dispatch over
`google.api_core.exceptions.*`, mirroring google-api-core's own default retry policy.
- **Azure SDK / azure-core** (when installed): type/status-based classification of
`azure.core.exceptions.*`, mirroring azure-core's own default retry policy.
- **Builtins**: `TimeoutError` (retryable), `ConnectionError`/`OSError` (retryable), `ValueError`
(non-retryable).

Expand Down Expand Up @@ -117,6 +124,109 @@ Non-retryable examples:
- `LockError` / `LockNotOwnedError`, `DataError`, `InvalidResponse`, and any other `ResponseError`
(syntax/argument errors, e.g. `WRONGTYPE`)

## AWS SDK / botocore

**AWS routinely returns HTTP `400` for throttling, not `429`** — many services (DynamoDB, Kinesis,
STS, and others) return `ProvisionedThroughputExceededException`/`ThrottlingException` as HTTP `400`,
the same status used for genuine permanent validation errors. Because of this, `retryguard` does
**not** classify AWS errors via the generic `classify_http_status` rule — that would misclassify
throttling as a permanent failure. Instead, `botocore.exceptions.ClientError` is classified primarily
by its `Error.Code` string (a stable, documented field — exact-equality reads, not message parsing,
the same category of thing as reading `sqlstate`), falling back to `HTTPStatusCode` only for codes
without a specific mapping.

AWS's service-specific "modeled" exceptions (e.g.
`DynamoDB.Client.exceptions.ProvisionedThroughputExceededException`) are generated dynamically per
boto3 client instance — not stable, importable classes — so this classification can't be type-based
the way the Redis rule is. The retryable/throttling `Error.Code` lists are pulled directly from
botocore's own internal retry policy (`botocore.retries.standard`), not invented.

Retryable examples:

- Connection-level `BotoCoreError`s: `ConnectTimeoutError`, `ReadTimeoutError`,
`EndpointConnectionError`, `ProxyConnectionError`, `ConnectionClosedError`, `HTTPClientError`
- `Error.Code` in botocore's own throttling list: `ThrottlingException`, `Throttling`,
`RequestLimitExceeded`, `ProvisionedThroughputExceededException`, `TooManyRequestsException`,
`SlowDown`, and others — **including when returned as HTTP `400`**
- `Error.Code` in botocore's own transient list: `RequestTimeout`, `RequestTimeoutException`,
`PriorRequestNotComplete`
- `ConditionalCheckFailedException` (DynamoDB optimistic-lock conflict) — not in botocore's own retry
lists (identical request retry can't fix it), but treated as retryable at the caller-redo-the-operation
level, same precedent as Postgres `40001` and Redis `WatchError`
- Unrecognized `Error.Code` with `HTTPStatusCode` `500/502/503/504` or `429`

Non-retryable examples:

- `NoCredentialsError`, `PartialCredentialsError`, `UnauthorizedSSOTokenError` — retrying doesn't fix
missing/bad credentials
- Unrecognized `Error.Code` with `HTTPStatusCode` `401`/`403`
- Any other unrecognized `Error.Code` (e.g. `ValidationException`, `ResourceNotFoundException`) —
defaults to non-retryable `CLIENT`, regardless of HTTP status

## Google Cloud / google-api-core

`google.api_core.exceptions.*` happens to expose a `.code` attribute that `retryguard`'s generic
HTTP-status extraction already reads — meaning most GCP exceptions would already be classified
"correctly" by `classify_http_status` alone. `retryguard` still classifies them explicitly via a
dedicated, type-based rule (registered *before* `classify_http_status` in the pipeline, not after)
so that GCP errors get GCP-specific reason codes independent of the generic HTTP status-code
tables, and so the two cases below — where GCP's own semantics genuinely diverge from generic HTTP
status-code conventions — are handled correctly:

- `Aborted` shares HTTP status `409` with ordinary conflict errors, which are non-retryable by
generic convention. But `ABORTED` in Google Cloud (most commonly Firestore/Spanner/BigTable
transaction contention) means the caller should retry the operation — same precedent as Postgres
`40001` and Redis `WatchError`.
- `DeadlineExceeded`/`GatewayTimeout`/`BadGateway` share HTTP statuses `504`/`502`, which are
retryable by generic convention. But google-api-core's own default retry policy
(`google.api_core.retry.retry_base.if_transient_error`) deliberately excludes these — a
timed-out RPC may have partially succeeded server-side, so blind retry isn't safe.

Retryable examples:

- `ResourceExhausted`/`TooManyRequests` (quota/rate-limit errors)
- `ServiceUnavailable`, `InternalServerError` — matches google-api-core's own default retry policy
- `Aborted` — transaction conflict; retry the operation (see above)

Non-retryable examples:

- `DeadlineExceeded`, `GatewayTimeout`, `BadGateway` — deliberately excluded from
google-api-core's own retry policy (see above)
- `InvalidArgument`, `FailedPrecondition`, `OutOfRange` (400) — genuine validation errors; unlike
AWS's HTTP `400`, GCP doesn't overload this status with throttling
- `Unauthorized`/`Unauthenticated`/`Forbidden`/`PermissionDenied` (401/403)
- Anything else (e.g. `NotFound`, `AlreadyExists`) — defaults to non-retryable `CLIENT`

## Azure SDK / azure-core

Like GCP, `azure.core.exceptions.HttpResponseError` exposes a `.status_code` attribute that
`retryguard`'s generic HTTP-status extraction already reads (`"status_code"` is in fact the
*first* attribute name it checks) — so `classify_azure` is registered *before*
`classify_http_status` in the pipeline, and classifies every case explicitly with its own
`azure_*` reason code rather than relying on the generic rule.

The one case that needs disambiguating by type, not status code: `ResourceModifiedError`
(ETag conflict on a conditional write — Storage, Cosmos DB, App Configuration) typically
carries HTTP `412`, same as `ResourceNotFoundError` can when raised on an update. `retryguard`
treats `ResourceModifiedError` as retryable (re-read and retry the operation) — same precedent
as Postgres `40001`, Redis `WatchError`, AWS `ConditionalCheckFailedException`, and GCP
`Aborted` — while a `ResourceNotFoundError` carrying the same status code stays non-retryable.

Retryable examples:

- `ServiceRequestError`/`ServiceResponseError` and their timeout variants (connection-level,
no response received)
- `ResourceModifiedError` — ETag conflict; retry the operation (see above)
- HTTP `408`, `429`, `500`, `502`, `503`, `504` — matches azure-core's own default retry policy

Non-retryable examples:

- `ClientAuthenticationError`, or HTTP `401`/`403`
- `ResourceNotModifiedError` (HTTP `304`) — not an error; retrying achieves nothing
- `TooManyRedirectsError`, `DecodeError` — client-side/protocol issues
- Anything else (e.g. `ResourceNotFoundError`, `ResourceExistsError`) — defaults to
non-retryable `CLIENT`

## Usage

```python
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dependencies = []
authors = [
{ name = "Rhanny Urbis", email = "raniurbis@gmail.com" }
]
keywords = ["retry", "error classification", "resilience", "celery", "tenacity", "httpx", "sqlalchemy", "postgres", "redis"]
keywords = ["retry", "error classification", "resilience", "celery", "tenacity", "httpx", "sqlalchemy", "postgres", "redis", "aws", "boto3", "gcp", "google-cloud", "azure"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
Expand All @@ -36,6 +36,9 @@ PyPI = "https://pypi.org/project/retryguard/"
http = ["httpx>=0.27.0,<2.0", "requests>=2.32.0,<3.0"]
db = ["SQLAlchemy>=2.0.0,<3.0", "asyncpg>=0.29.0,<1.0", "psycopg>=3.1.0,<4.0"]
redis = ["redis>=4.2.0,<6.0"]
aws = ["botocore>=1.34.0,<2.0"]
gcp = ["google-api-core>=2.0.0,<3.0"]
azure = ["azure-core>=1.28.0,<2.0"]
retry = ["tenacity>=8.2.0,<10.0"]
dev = ["pytest>=8.0.0", "pytest-cov>=5.0.0", "coverage[toml]>=7.0.0", "ruff>=0.6.0"]

Expand Down
10 changes: 10 additions & 0 deletions src/retryguard/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

from .models import RetryCategory, RetryDecision
from .rules import (
classify_aws,
classify_azure,
classify_builtin,
classify_gcp,
classify_http_status,
classify_httpx,
classify_postgres_sqlstate,
Expand All @@ -20,10 +23,17 @@


DEFAULT_RULES: tuple[ClassifierRule, ...] = (
# classify_gcp/classify_azure must precede classify_http_status: their
# exceptions expose a `.code`/`.status_code` attribute that
# extract_status_code already reads, so classify_http_status would otherwise
# intercept every GCP/Azure exception first.
classify_gcp,
classify_azure,
classify_http_status,
classify_httpx,
classify_requests,
classify_redis,
classify_aws,
classify_sqlalchemy,
classify_builtin,
classify_postgres_sqlstate,
Expand Down
Loading
Loading