Skip to main content

Rate-limit declaration toolkit: throttle policies, tier rates, and declaration decorators for Python services

Project description

domain-api-limiter

PyPI CI License Python

Declarative rate-limit policy toolkit for Python services. Attach throttle policies to methods, parse rates, define tier overrides, and collect policies for consumption by framework-level throttling adapters.

Why domain-api-limiter?

Rate-limiting policies are declarations: they define what throttle rules apply to a service method, but they never enforce the limit themselves. Enforcement belongs in the consuming framework's vetted throttling logic (Django REST Framework throttles, Starlette middleware, etc.). domain-api-limiter keeps the declaration layer simple, immutable, and composable. Policies are frozen dataclasses, validated at import time so malformed rates fail fast. The @throttled decorator attaches policy metadata to any method without changing its signature. PolicyRegistry collects declared policies from a service class or module, returning them in definition order so an adapter can build the framework's real throttle classes. Tier overrides let you apply different rate limits by customer plan (free/pro/enterprise) without duplicating policy declarations.

Installation

pip install domain-api-limiter

Or with uv:

uv add domain-api-limiter

Requires Python 3.11+. Depends on domain-errors >= 0.1.0.

Quick Start

1. Declare throttle policies with @throttled (method or class level)

Apply @throttled to individual methods:

from domain_api_limiter import throttled

class DocumentService:
    @throttled("docs:list", "100/hour", tiers={"free": "10/day", "pro": "1000/hour"})
    def list_documents(self, tenant_id: str) -> list:
        """List all documents for a tenant."""
        return []

    @throttled("docs:upload", "20/minute")
    def upload(self, tenant_id: str) -> None:
        """Upload a document."""
        pass

Or apply to the entire class; each public method gets scope root:method_name:

@throttled("docs", "100/hour", tiers={"free": "10/day", "pro": "1000/hour"})
class DocumentService:
    def list_documents(self, tenant_id: str) -> list:
        """List all documents for a tenant."""
        return []

    def upload(self, tenant_id: str) -> None:
        """Upload a document."""
        pass

    def _internal_helper(self) -> None:
        """Private methods are skipped."""
        pass

In class-level mode, list_documents gets scope docs:list_documents and upload gets docs:upload. Private methods (_-prefixed), dunders, properties, and nested classes are skipped. Methods already decorated with @throttled keep their individual policies.

The decorator validates the rate and tier strings at decoration time and attaches a ThrottlePolicy without changing method behavior.

Class-level @throttled with PolicyRegistry.collect() output:

from domain_api_limiter import throttled, PolicyRegistry

@throttled("docs", "100/hour", tiers={"free": "10/day", "pro": "1000/hour"})
class DocumentService:
    def list_documents(self, tenant_id: str) -> list:
        return []

    def upload(self, tenant_id: str) -> None:
        pass

    @throttled("docs:delete", "50/day")
    def delete_document(self, doc_id: str) -> None:
        """Overrides class-level policy."""
        pass

    def _internal_helper(self) -> None:
        """Private methods are skipped."""
        pass

    @staticmethod
    def validate_name(name: str) -> bool:
        return bool(name)

registry = PolicyRegistry()
policies = registry.collect(DocumentService)

Output:

Collected 4 policies from DocumentService:

Scope: docs:list_documents
Base rate: 100/hour
  free: 10/day
  pro: 1000/hour

Scope: docs:upload
Base rate: 100/hour
  free: 10/day
  pro: 1000/hour

Scope: docs:delete
Base rate: 50/day

Scope: docs:validate_name
Base rate: 100/hour
  free: 10/day
  pro: 1000/hour

2. Collect policies for your adapter

from domain_api_limiter import PolicyRegistry

registry = PolicyRegistry()
policies = registry.collect(DocumentService)
print(f"Collected {len(policies)} policies:")
# Output: Collected 2 policies:
#   docs:list: 100/hour (tiers: free=10/day, pro=1000/hour)
#   docs:upload: 20/minute

Your adapter then walks the policies and builds the framework's real throttle classes (DRF throttles, middleware, etc.).

3. Parse rates and inspect tier overrides

from domain_api_limiter import RateLimit, Period

# Parse rate strings
rate = RateLimit.from_rate("100/hour")
print(rate.requests)      # 100
print(rate.period)         # Period.HOUR
print(rate.period_seconds) # 3600
print(rate.as_rate())      # "100/hour"

# Create rates directly
rate = RateLimit(requests=50, period=Period.MINUTE)

# Inspect tier overrides on a collected policy
policy = policies[0]
if policy.has_tiers:
    free_rate = policy.rate_for("free")       # Returns RateLimit for "free" tier
    pro_rate = policy.rate_for("pro")         # Returns RateLimit for "pro" tier
    default_rate = policy.rate_for("unknown") # Returns the base rate

4. Handle declaration errors

from domain_api_limiter import ThrottleDeclarationError

# Errors are raised at decoration time, not at runtime.
try:
    @throttled("scope", "invalid")  # Malformed rate
    def bad_method():
        pass
except ThrottleDeclarationError as e:
    print(f"Invalid policy: {e.message}")

Service Class Example

A typical service class with throttled methods:

from domain_api_limiter import throttled, PolicyRegistry

class DocumentService:
    @throttled("docs:list", "100/hour", tiers={"free": "10/day", "pro": "1000/hour"})
    def list_documents(self, tenant_id: str) -> list:
        """List all documents for a tenant."""
        return []

    @throttled("docs:upload", "20/minute")
    def upload(self, tenant_id: str) -> None:
        """Upload a document."""
        pass

# An adapter collects policies and builds throttle classes
registry = PolicyRegistry()
policies = registry.collect(DocumentService)

for policy in policies:
    print(f"Scope: {policy.scope}")
    print(f"Base rate: {policy.rate.as_rate()}")
    if policy.has_tiers:
        for tier_rate in policy.tier_rates:
            print(f"  {tier_rate.tier}: {tier_rate.rate.as_rate()}")

Output:

Scope: docs:list
Base rate: 100/hour
  free: 10/day
  pro: 1000/hour
Scope: docs:upload
Base rate: 20/minute

Public API

Throttle Declaration

from domain_api_limiter import throttled, ThrottlePolicy, RateLimit, TierRate, Period

# Decorator: attach a policy to a method
@throttled(scope: str, rate: str, tiers: dict[str, str] | None = None)
def my_method() -> None:
    pass

# Value objects (immutable, validated at creation)
policy = ThrottlePolicy(
    scope: str,                      # Policy identifier (e.g., "docs:list")
    rate: RateLimit,                 # Base rate for all tiers
    tier_rates: tuple[TierRate, ...] # Per-tier rate overrides (default: ())
)

rate = RateLimit(
    requests: int,  # Positive count of allowed requests
    period: Period  # Period enum: SECOND, MINUTE, HOUR, DAY
)

tier_rate = TierRate(
    tier: str,       # Non-empty tier label (e.g., "free", "pro")
    rate: RateLimit  # Rate override for this tier
)

# Period enumeration
from domain_api_limiter import Period
period = Period.HOUR  # or SECOND, MINUTE, DAY

Policy Registry

from domain_api_limiter import PolicyRegistry

registry = PolicyRegistry()

# Retrieve the policy from a single callable
policy = registry.policy_of(my_method)  # Returns ThrottlePolicy | None

# Collect all policies from a class or module
policies = registry.collect(DocumentService)  # Returns tuple[ThrottlePolicy, ...]

Error Types

from domain_api_limiter import ThrottleError, RateLimitExceeded, ThrottleDeclarationError

# Base error for all rate-limit domain failures
# domain="api_limiter", code="throttle_error", http_status=429, retryable=True
class ThrottleError(DomainError):
    pass

# Request rejected because a rate limit was exceeded
# code="rate_limit_exceeded", http_status=429, retryable=True
class RateLimitExceeded(ThrottleError):
    pass

# Invalid throttle declaration detected at import time
# code="throttle_declaration_invalid", http_status=500, retryable=False
class ThrottleDeclarationError(ThrottleError):
    pass

All errors inherit from domain-errors DomainError and integrate with structured logging and error tracking systems.

Documentation

For detailed documentation on each feature, see:

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.

Contributing

This library is maintained by James Ekhator. Contributions welcome via pull requests. Please read CONTRIBUTING.md for guidelines and CODE_OF_CONDUCT.md for community standards.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

domain_api_limiter-0.2.0.tar.gz (81.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

domain_api_limiter-0.2.0-py3-none-any.whl (16.7 kB view details)

Uploaded Python 3

File details

Details for the file domain_api_limiter-0.2.0.tar.gz.

File metadata

  • Download URL: domain_api_limiter-0.2.0.tar.gz
  • Upload date:
  • Size: 81.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for domain_api_limiter-0.2.0.tar.gz
Algorithm Hash digest
SHA256 60562e9804f1d78e3874144a9ba0dffb4f8666b851ca285d0198b4d3a7449c97
MD5 6e66e6f9e4e44066cf3539780525d254
BLAKE2b-256 6f5faf4d4d441e1b463fdeb856b6e43155ff4b431800fae0a276fa4583dc00c0

See more details on using hashes here.

Provenance

The following attestation bundles were made for domain_api_limiter-0.2.0.tar.gz:

Publisher: publish.yml on jekhator/domain-api-limiter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file domain_api_limiter-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for domain_api_limiter-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6a3943726ab723789c0c5a0c6843ea61bcfdba572a02e6ef66c57f9413abcdb6
MD5 307b96ada73086b9561bdd5ebab88a58
BLAKE2b-256 0a12b2e430725413858f0a34fec0d2c0b9e1ebeb149084b778822882aa557cd1

See more details on using hashes here.

Provenance

The following attestation bundles were made for domain_api_limiter-0.2.0-py3-none-any.whl:

Publisher: publish.yml on jekhator/domain-api-limiter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page