Skip to content

Commit

Permalink
0.3.0
Browse files Browse the repository at this point in the history
  • Loading branch information
bagowix committed Jan 15, 2025
1 parent d625ef5 commit 91cfc76
Show file tree
Hide file tree
Showing 7 changed files with 462 additions and 141 deletions.
29 changes: 26 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,38 @@ We follow [Semantic Versions](https://semver.org/).

## WIP

## Version 0.2.5
## Version 0.3.0

### Features

- Introduced a custom error class `RatelimitIOError`, designed to mirror the behavior of `HTTPException`, making it easier to integrate with web frameworks like Flask, Django, and FastAPI.
- Enhanced decorator functionality
- Automatically applies `base_limit` when the decorator is used without explicit arguments.
- Added seamless support for distinguishing between incoming and outgoing requests using the `is_incoming` flag.
- Implemented 429 Too Many Requests handling:
- Provides examples for integrating with Flask, Django, and FastAPI to manage error responses gracefully.

### Fixes

- Fixed an issue where `unique_key` fallback to `default_key` was inconsistent.
- Improved Redis script loading to ensure proper error handling and robustness during high-concurrency operations.

### Misc

- Updated README with examples for:
- Using decorators and context managers for both sync and async workflows.
- Handling `429 Too Many Requests` errors in popular frameworks.
- Enhanced test coverage to verify all added features and scenarios.

## Version 0.2.5

### Misc

- Adds shields `downloads`

## Version 0.2.4

### Features
### Misc

- Edits README.md

Expand All @@ -36,7 +59,7 @@ We follow [Semantic Versions](https://semver.org/).

## Version 0.2.0

### Features
### Misc

- Adds wheel builds to GitHub Actions

Expand Down
120 changes: 109 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RatelimitIO

A Python library for rate limiting, built to handle both incoming and outgoing requests efficiently. Supports both synchronous and asynchronous paradigms. Powered by Redis, this library provides decorators, context managers, and easy integration with APIs to manage request limits with precision.
A Python library for rate limiting, designed to handle both incoming and outgoing requests efficiently. Supports both synchronous and asynchronous paradigms. Powered by Redis, this library provides decorators, context managers, and easy integration with APIs to manage request limits with precision.

#### Project Information
[![Tests & Lint](https://github.com/bagowix/ratelimit-io/actions/workflows/actions.yml/badge.svg)](https://github.com/bagowix/ratelimit-io/actions/workflows/actions.yml)
Expand All @@ -13,7 +13,7 @@ A Python library for rate limiting, built to handle both incoming and outgoing r

## Features

- **Incoming and Outgoing Support**: Effectively handles limits for both inbound
- **Incoming and Outgoing Support**: Effectively handles limits for both inbound (e.g., API requests) and outbound (e.g., client requests to external APIs) traffic.
- **Synchronous and Asynchronous Support**: Works seamlessly in both paradigms.
- **Redis Backend**: Leverages Redis for fast and scalable rate limiting.
- **Flexible API**:
Expand All @@ -22,6 +22,8 @@ A Python library for rate limiting, built to handle both incoming and outgoing r
- Integrate directly into API clients, middlewares, or custom request handlers.
- **Customizable Rate Limits**: Specify limits per key, time period, and requests.
- **Robust Lua Script**: Ensures efficient and atomic rate limiting logic for high-concurrency use cases.
- **Automatic Error Handling**: Easily manage 429 Too Many Requests errors in popular frameworks like Flask, Django, and FastAPI.
- **Support for Incoming Request Behavior**: Use the `is_incoming` flag to distinguish between incoming requests (throwing errors immediately) and outgoing requests (waiting for available slots).
- **Ease of Use**: Simple and intuitive integration into Python applications.

---
Expand All @@ -43,49 +45,145 @@ from ratelimit_io import RatelimitIO, LimitSpec
from redis import Redis

redis_client = Redis(host="localhost", port=6379)
limiter = RatelimitIO(backend=redis_client)
limiter = RatelimitIO(backend=redis_client, base_limit=LimitSpec(requests=10, seconds=60))

@limiter(LimitSpec(requests=10, seconds=60), unique_key="user:123") # unique_key is optional
@limiter
def fetch_data():
return "Request succeeded!"

# Use the decorated function
fetch_data()
```

**Note**: The `unique_key` parameter is `optional`. If not provided, the `IP address` (or another default identifier, based on your application logic) will be used to apply rate limiting.

### Using as a Asynchronous Decorator

```python
from ratelimit_io import RatelimitIO, LimitSpec
from redis.asyncio import Redis as AsyncRedis

async_redis_client = AsyncRedis(host="localhost", port=6379)
async_limiter = RatelimitIO(backend=async_redis_client)
async_limiter = RatelimitIO(backend=async_redis_client, base_limit=LimitSpec(requests=10, seconds=60))

@async_limiter(LimitSpec(requests=10, seconds=60), unique_key="user:123") # unique_key is optional
@async_limiter
async def fetch_data():
return "Request succeeded!"

# Use the decorated function
await fetch_data()
```

**Note**: Similar to the synchronous decorator, the `unique_key` is `optional`. If omitted, the `IP address` (or a default identifier) will be used.
### Incoming vs. Outgoing Request Handling (`is_incoming`)

- When `is_incoming=True`, the rate limiter will immediately raise a `RatelimitIOError` when limits are exceeded.
- When `is_incoming=False` (default), the rate limiter will wait until a slot becomes available.

```python
# Incoming request example (throws an error on limit breach)
limiter = RatelimitIO(backend=redis_client, is_incoming=True)

@limiter(LimitSpec(requests=5, seconds=10))
def fetch_data():
return "Request succeeded!"

# Outgoing request example (waits if limits are exceeded)
outgoing_limiter = RatelimitIO(backend=redis_client)

@outgoing_limiter(LimitSpec(requests=5, seconds=10))
def fetch_data_outgoing():
return "Request succeeded!"
```

### Using as a Synchronous Context Manager

```python
from ratelimit_io import RatelimitIO, LimitSpec
from redis import Redis

redis_client = Redis(host="localhost", port=6379)
limiter = RatelimitIO(backend=redis_client)

with limiter:
limiter.wait("user:456", LimitSpec(requests=5, seconds=10))
```

### Using as a Asynchronous Context Manager

```python
async with limiter:
await limiter.a_wait("user:456", LimitSpec(requests=5, seconds=10))
from ratelimit_io import RatelimitIO, LimitSpec
from redis.asyncio import Redis as AsyncRedis

async_redis_client = AsyncRedis(host="localhost", port=6379)
async_limiter = RatelimitIO(backend=async_redis_client)

async with async_limiter:
await async_limiter.a_wait("user:456", LimitSpec(requests=5, seconds=10))
```

## Error Handling for 429 Responses

### FastAPI Example

```python
from fastapi import FastAPI, HTTPException, Request
from ratelimit_io import RatelimitIO, RatelimitIOError, LimitSpec
from redis.asyncio import Redis as AsyncRedis

app = FastAPI()
redis_client = AsyncRedis(host="localhost", port=6379)
limiter = RatelimitIO(backend=redis_client, is_incoming=True)

@app.middleware("http")
async def ratelimit_middleware(request: Request, call_next):
try:
await limiter.a_wait(f"user:{request.client.host}", LimitSpec(10, seconds=60))
except RatelimitIOError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
return await call_next(request)

@app.get("/fetch")
async def fetch_data():
return {"message": "Request succeeded!"}
```

### Django Middleware Example

```python
from django.http import JsonResponse
from ratelimit_io import RatelimitIO, RatelimitIOError, LimitSpec
from redis import Redis

redis_client = Redis(host="localhost", port=6379)
limiter = RatelimitIO(backend=redis_client, is_incoming=True)

def ratelimit_middleware(get_response):
def middleware(request):
try:
limiter.wait(f"user:{request.META['REMOTE_ADDR']}", LimitSpec(10, seconds=60))
except RatelimitIOError as e:
return JsonResponse({"error": e.detail}, status=e.status_code)
return get_response(request)
return middleware
```

### Flask Example

```python
from flask import Flask, jsonify
from ratelimit_io import RatelimitIO, RatelimitIOError, LimitSpec
from redis import Redis

app = Flask(__name__)
redis_client = Redis(host="localhost", port=6379)
limiter = RatelimitIO(backend=redis_client, is_incoming=True)

@app.errorhandler(RatelimitIOError)
def handle_ratelimit_error(error):
return jsonify({"error": error.detail}), error.status_code

@app.route("/fetch")
@limiter
def fetch_data():
return jsonify({"message": "Request succeeded!"})
```

## License
Expand Down
2 changes: 1 addition & 1 deletion coverage.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "ratelimit-io"
version = "0.2.5"
version = "0.3.0"
description = "Flexible bidirectional rate-limiting library with redis backend"
authors = ["Galushko Bogdan <[email protected]>"]
license = "MIT"
Expand Down
76 changes: 9 additions & 67 deletions ratelimit_io/__init__.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,9 @@
from typing import Optional
from typing import Union

from redis import Redis
from redis.asyncio import Redis as AsyncRedis
from typing_extensions import TypeAlias

from ratelimit_io.rate_limit import RatelimitIO # noqa


class RatelimitIOError(Exception):
"""Raised when the rate limit is exceeded."""


class LimitSpec:
"""
Specifies the number of requests allowed in a time frame.
Attributes:
requests (int): Maximum number of requests.
seconds (Optional[int]): Time frame in seconds.
minutes (Optional[int]): Time frame in minutes.
hours (Optional[int]): Time frame in hours.
"""

def __init__(
self,
requests: int,
seconds: Optional[int] = None,
minutes: Optional[int] = None,
hours: Optional[int] = None,
):
if requests <= 0:
raise ValueError("Requests must be greater than 0.")

self.requests = requests
self.seconds = seconds
self.minutes = minutes
self.hours = hours

if self.total_seconds() == 0:
raise ValueError(
"At least one time frame "
"(seconds, minutes, or hours) must be provided."
)

def total_seconds(self) -> int:
"""
Calculates the total time frame in seconds.
Returns:
int: Total time in seconds.
"""
total = 0
if self.seconds:
total += self.seconds
if self.minutes:
total += self.minutes * 60
if self.hours:
total += self.hours * 3600
return total

def __str__(self) -> str:
return f"{self.requests}/{self.total_seconds()}s"


RedisBackend: TypeAlias = Union[Redis, AsyncRedis]
from ratelimit_io.rate_limit import LimitSpec
from ratelimit_io.rate_limit import RatelimitIO
from ratelimit_io.rate_limit import RatelimitIOError

__all__ = [
"RatelimitIO",
"LimitSpec",
"RatelimitIOError",
]
Loading

0 comments on commit 91cfc76

Please sign in to comment.