Exceptions¶
Exception types raised by zae-limiter.
Exception Hierarchy¶
ZAELimiterError (base)
├── RateLimitError
│ ├── RateLimitExceeded
│ └── LeaseExpiredError
├── EntityError
│ ├── EntityNotFoundError
│ └── EntityExistsError
├── InfrastructureError
│ ├── RateLimiterUnavailable
│ │ └── PermissionDenied
│ ├── StackOperationError
│ ├── StackAlreadyExistsError
│ ├── InfrastructureNotFoundError
│ ├── NamespaceNotFoundError
│ └── NamespaceStateError
├── VersionError
│ ├── VersionMismatchError
│ └── IncompatibleSchemaError
├── ValidationError
│ ├── InvalidIdentifierError
│ └── InvalidNameError
├── ResourceDisabled
└── FanoutIncomplete
Base Exception¶
ZAELimiterError
¶
Bases: Exception
Base exception for all zae-limiter errors.
All exceptions raised by this library inherit from this class, allowing callers to catch all library-specific errors with a single except clause.
Rate Limit Exceptions¶
RateLimitExceeded
¶
Bases: RateLimitError
Raised when one or more rate limits would be exceeded.
Provides full visibility into ALL limits that were checked, both passed and failed, to help callers understand the full picture.
Attributes:
| Name | Type | Description |
|---|---|---|
statuses |
Status of ALL limits checked (both passed and failed) |
|
violations |
Only the limits that were exceeded |
|
passed |
Only the limits that passed |
|
retry_after_seconds |
Time until ALL requested capacity is available |
|
primary_violation |
The violation with longest retry time (bottleneck) |
|
contended |
True when the request was not rejected for lack of
capacity but because its write kept colliding with concurrent
DynamoDB transactions on the same bucket item (#724). Nothing was
debited; |
retry_after_header
property
¶
Value for HTTP Retry-After header (integer seconds).
as_dict
¶
Serialize for JSON API responses.
Returns a dictionary suitable for returning in a 429 response body.
Per-limit entries carry a kind of "rate" or "quota" and,
for a quota, resets_at_ms in place of the drip fields — see
:meth:_limit_shape. Every entry also carries soft (#467): a soft
limit is metered but never a reason to reject, so it is always among
the passed limits and its available may be negative.
LeaseExpiredError
¶
Bases: RateLimitError
Raised when a lease operation is attempted after the lease has exited.
Entity Exceptions¶
EntityNotFoundError
¶
Bases: EntityError
Raised when an entity is not found.
EntityExistsError
¶
Bases: EntityError
Raised when trying to create an entity that already exists.
Infrastructure Exceptions¶
RateLimiterUnavailable
¶
Bases: InfrastructureError
Raised when DynamoDB is unavailable and on_unavailable=OnUnavailable.BLOCK.
This indicates a transient infrastructure issue, not a rate limit. When using OnUnavailable.BLOCK (the default), your application should be prepared to catch this exception and handle degraded mode gracefully.
Attributes:
| Name | Type | Description |
|---|---|---|
cause |
The underlying exception that caused the unavailability |
|
stack_name |
The stack/table that was being accessed |
|
entity_id |
The entity being rate limited (if applicable) |
|
resource |
The resource being rate limited (if applicable) |
PermissionDenied
¶
Bases: RateLimiterUnavailable
Raised when DynamoDB refuses a call for lack of an IAM permission.
acquire() raises it on an AccessDeniedException anywhere in the
error's cause chain, whatever on_unavailable says (#751).
OnUnavailable.ALLOW covers an unavailable backend, which recovers on
its own; a missing permission does not. The denied write is the one that
would create or update the bucket, so no bucket ever exists and ALLOW
would admit every later call with no limit at all, silently.
A subclass of :class:RateLimiterUnavailable, so a handler written for
BLOCK still catches it. The message carries AWS's own text, which names
the missing action, and a link to the IAM policies acquire() needs.
Attributes:
| Name | Type | Description |
|---|---|---|
cause |
The underlying exception (the error |
|
stack_name |
The stack/table that was being accessed |
|
entity_id |
The entity being rate limited (if applicable) |
|
resource |
The resource being rate limited (if applicable) |
StackOperationError
¶
Bases: InfrastructureError
Raised when a CloudFormation stack operation fails.
StackAlreadyExistsError
¶
Bases: InfrastructureError
Raised when a stack already exists.
InfrastructureNotFoundError
¶
Bases: InfrastructureError
Raised when expected infrastructure doesn't exist.
This typically means the CloudFormation stack or DynamoDB table hasn't been deployed yet.
NamespaceNotFoundError
¶
Bases: InfrastructureError
Raised when a namespace is not found in the registry.
NamespaceStateError
¶
Bases: InfrastructureError
Raised when a namespace operation is invalid for the current state.
Examples: recovering an active namespace, purging an active namespace, recovering a namespace whose name was re-registered.
Attributes:
| Name | Type | Description |
|---|---|---|
namespace_name |
The namespace name (if known). |
|
state |
The current state that caused the conflict (e.g. "active", "purging"). |
Version Exceptions¶
VersionMismatchError
¶
VersionMismatchError(client_version, schema_version, lambda_version, message, can_auto_update=False)
Bases: VersionError
Raised when client and infrastructure versions are incompatible.
This error indicates that the client library version doesn't match the deployed infrastructure and auto-update is disabled or failed.
IncompatibleSchemaError
¶
Bases: VersionError
Raised when schema version requires manual migration.
This indicates a major version difference that cannot be automatically reconciled.
Validation Exceptions¶
ValidationError
¶
Bases: ZAELimiterError
Base exception for input validation errors.
This includes errors raised when invalid input is provided to models, such as invalid entity IDs, limit names, or resource names.
Attributes:
| Name | Type | Description |
|---|---|---|
field |
The name of the field that failed validation |
|
value |
The invalid value (truncated if too long) |
|
reason |
Human-readable explanation of why validation failed |
InvalidIdentifierError
¶
Bases: ValidationError
Raised when an identifier (entity_id, parent_id) is invalid.
Identifiers must: - Not be empty - Not contain the '#' character (used as key delimiter) - Not exceed 256 characters - Start with alphanumeric character - Contain only: alphanumeric, underscore, hyphen, dot, colon, @
InvalidNameError
¶
Bases: ValidationError
Raised when a name (limit_name, resource) is invalid.
Names must: - Not be empty - Start with a letter - Contain only alphanumeric characters, underscores, hyphens, and dots - Not exceed 64 characters - Not contain the '#' character
Configuration State Exceptions¶
ResourceDisabled
¶
Bases: ZAELimiterError
Raised when a resource is disabled for the requesting entity.
This is a configuration state, not a throttling signal: it does not inherit from RateLimitError and carries no retry hint, because retrying will not help. Map it to 403, not 429.
The resolved value comes from the first level that sets disabled
explicitly, walking entity -> entity default -> resource (ADR-125).
Attributes:
| Name | Type | Description |
|---|---|---|
entity_id |
Entity that attempted the acquire |
|
resource |
Resource that is disabled |
|
level |
Config level that decided it ("entity", "entity_default", "resource", or "bucket" when the decision came from the denormalized bucket attribute on the fast path) |
FanoutIncomplete
¶
Bases: ZAELimiterError
Raised when a config fan-out to bucket items fails partway through.
Two fan-outs raise this: the disable/enable stamp (ADR-125) and the
limit-param sync that follows set_limits/delete_limits (#468,
487). Both share the same hazard.¶
The config write lands before the fan-out begins, so a failure here
leaves the table half-applied: the config item carries the new value,
some bucket items match and the rest still hold the old one. Stale
buckets keep passing the speculative fast path, which tests
attribute_not_exists(#disabled) and the bucket's own stored
cp/ra and never re-reads config.
Nothing reconciles this on its own. Buckets backed by entity-level custom limits carry no TTL, so the drift persists until the same command is run again — which is safe to do, since both the config write and each bucket write are idempotent.
Attributes:
| Name | Type | Description |
|---|---|---|
stamped |
Bucket items successfully written before the failure |
|
resource |
Resource being changed, if scoped to one |
|
entity_id |
Entity being changed, if scoped to one |
|
cause |
The underlying exception that stopped the fan-out |
Exception Handling Examples¶
Connection Errors¶
from zae_limiter import Repository, NamespaceNotFoundError
try:
repo = await Repository.open("tenant-alpha")
except NamespaceNotFoundError as e:
# Namespace not registered — register it first or check for typos
print(f"Namespace not found: {e.namespace_name}")
Basic Handling¶
from zae_limiter import RateLimitExceeded, RateLimiterUnavailable
try:
async with limiter.acquire(
entity_id="user-123",
resource="api",
limits=[Limit.per_minute("rpm", 100)],
consume={"rpm": 1},
):
await do_work()
except RateLimitExceeded as e:
# Handle rate limit exceeded
print(f"Rate limited. Retry after {e.retry_after_seconds}s")
except RateLimiterUnavailable as e:
# Handle service unavailable
print(f"Service unavailable: {e}")
HTTP API Response¶
from fastapi import HTTPException
from fastapi.responses import JSONResponse
@app.post("/api/endpoint")
async def endpoint():
try:
async with limiter.acquire(...):
return await process_request()
except RateLimitExceeded as e:
return JSONResponse(
status_code=429,
content=e.as_dict(),
headers={"Retry-After": e.retry_after_header},
)
except RateLimiterUnavailable:
raise HTTPException(status_code=503, detail="Service temporarily unavailable")
Detailed Error Information¶
try:
async with limiter.acquire(
entity_id="user-123",
resource="gpt-4",
limits=[Limit.per_minute("rpm", 1)],
consume={"rpm": 2}, # Exceeds capacity to trigger error
):
pass
except RateLimitExceeded as e:
# All limit statuses (both passed and failed)
for status in e.statuses:
print(f"Limit: {status.limit_name}")
print(f" Entity: {status.entity_id}")
print(f" Available: {status.available}")
print(f" Requested: {status.requested}")
print(f" Exceeded: {status.exceeded}")
print(f" Retry after: {status.retry_after_seconds}s")
# Only the violations
print(f"Violations: {len(e.violations)}")
for v in e.violations:
print(f" - {v.limit_name}: {v.available} available")
# Only the passed limits
print(f"Passed: {len(e.passed)}")
# Primary bottleneck (longest wait time). Always set; prefer it to
# `e.violations[0]`, which is empty on a contended 429 (see below)
print(f"Bottleneck: {e.primary_violation.limit_name}")
print(f"Total retry after: {e.retry_after_seconds}s")
as_dict() Output¶
The as_dict() method returns a dictionary suitable for API responses:
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded for user-123/api: [rpm]. Retry after 45.2s",
"contended": False,
"retry_after_seconds": 45.2,
"retry_after_ms": 45200,
"limits": [
{
"entity_id": "user-123",
"resource": "api",
"limit_name": "rpm",
"kind": "rate",
"capacity": 100,
"refill_amount": 100,
"refill_period_seconds": 60,
"available": -5,
"requested": 10,
"exceeded": True,
"retry_after_seconds": 45.2,
},
{
"entity_id": "user-123",
"resource": "api",
"limit_name": "rpd",
"kind": "quota",
"capacity": 10000,
"resets_at_ms": 1789531200000,
"available": 0,
"requested": 500,
"exceeded": True,
"retry_after_seconds": 29000.0,
},
],
}
Single limits array
Every limit declared in acquire(consume=...) — both exceeded and passed — is
returned in a single limits array. Limits you did not name in consume are
never reported, since they never gate admission. Use the exceeded field to
distinguish between violations and passed limits.
contended: retry shortly¶
A RateLimitExceeded with contended == True does not mean the entity is out of
capacity. Its write kept colliding with concurrent DynamoDB transactions on the same
bucket item (TransactionConflictException), so the limiter gave up after a few
retries instead of treating short-lived contention as an outage. Nothing was debited.
retry_after_seconds is short (about 0.1 s). Return it as a 429 like any other
rejection; the client should simply retry soon.
violations can be empty
On a contended 429 every status has exceeded: false, so e.violations is an
empty list and e.violations[0] raises IndexError. primary_violation is
always set: on a contended 429 it is the declared limit with the longest wait.
Check contended first, or read primary_violation / retry_after_seconds,
which are safe for every RateLimitExceeded:
kind: how the limit recovers¶
Each entry carries a kind, and the fields describing recovery differ by kind.
Read kind directly — do not infer a quota from refill_amount == 0.
kind |
Recovery fields | Meaning |
|---|---|---|
"rate" |
refill_amount, refill_period_seconds |
Drips back continuously at refill_amount per refill_period_seconds. |
"quota" |
resets_at_ms |
Does not drip at all (ADR-137). The whole allowance returns in one lump — at a calendar instant (cron), or when the entity's own session window ends (reset_after). |
capacity, available, requested, exceeded and retry_after_seconds are
present on both kinds.
resets_at_ms is an absolute epoch-millisecond instant, so a client can
schedule a retry without parsing cron and without a reference clock of its own.
The key is always present on a quota entry, and carries a real instant for every
practical quota period — session, daily, weekly, monthly, quarterly, annual.
For a calendar quota (cron) it is null only when the next reset is further out
than the reset pattern's own cycle — in practice only a pattern that skips whole
years, such as 0 0 29 2 * firing on a leap day. Treat null as "no scheduled
reset in reach", not as "never resets", and fall back to retry_after_seconds.
For a session quota (reset_after, ADR-139)
it is the end of the entity's current window, read off the bucket rather than
computed from a pattern, and never an instant in the past. It is null only when
no window is live — the previous one has ended, so the allowance is already back
and the next admitted request opens a fresh window. A session quota that caused the
rejection carries an instant in practice, since a rejection happens inside a live window.
A quota never reports refill_amount
A quota's stored refill_amount is fixed at 0 and its refill_period_seconds
is an inert placeholder. Both are omitted rather than serialized, because a
client dividing one by the other would compute "0 tokens per second, never
recovers" for a limit that in fact returns whole at resets_at_ms.