Skip to content

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

RateLimitExceeded(statuses, *, contended=False)

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; violations may be empty, primary_violation is then the declared limit with the longest wait, and retry_after_seconds is short ("contended, retry shortly").

retry_after_header property

retry_after_header

Value for HTTP Retry-After header (integer seconds).

as_dict

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

LeaseExpiredError()

Bases: RateLimitError

Raised when a lease operation is attempted after the lease has exited.

Entity Exceptions

EntityNotFoundError

EntityNotFoundError(entity_id)

Bases: EntityError

Raised when an entity is not found.

EntityExistsError

EntityExistsError(entity_id)

Bases: EntityError

Raised when trying to create an entity that already exists.

Infrastructure Exceptions

RateLimiterUnavailable

RateLimiterUnavailable(message, cause=None, *, stack_name=None, entity_id=None, resource=None)

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

PermissionDenied(message, cause=None, *, stack_name=None, entity_id=None, resource=None)

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 acquire() caught)

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

StackOperationError(stack_name, reason, events=None)

Bases: InfrastructureError

Raised when a CloudFormation stack operation fails.

StackAlreadyExistsError

StackAlreadyExistsError(stack_name)

Bases: InfrastructureError

Raised when a stack already exists.

InfrastructureNotFoundError

InfrastructureNotFoundError(stack_name)

Bases: InfrastructureError

Raised when expected infrastructure doesn't exist.

This typically means the CloudFormation stack or DynamoDB table hasn't been deployed yet.

NamespaceNotFoundError

NamespaceNotFoundError(namespace_name)

Bases: InfrastructureError

Raised when a namespace is not found in the registry.

NamespaceStateError

NamespaceStateError(message, *, namespace_name='', state='')

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

IncompatibleSchemaError(client_version, schema_version, message, migration_guide_url=None)

Bases: VersionError

Raised when schema version requires manual migration.

This indicates a major version difference that cannot be automatically reconciled.

Validation Exceptions

ValidationError

ValidationError(field, value, reason)

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

InvalidIdentifierError(field, value, reason)

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

InvalidNameError(field, value, reason)

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

ResourceDisabled(entity_id, resource, level)

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

FanoutIncomplete(stamped, cause, *, resource=None, entity_id=None, action='stamping')

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:

from zae_limiter import RateLimitExceeded


def describe(e: RateLimitExceeded) -> str:
    if e.contended:
        return f"busy, retry in {e.retry_after_seconds:.1f}s"
    return f"{e.primary_violation.limit_name} exhausted"

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.