Skip to content

0.5.0

Compare
Choose a tag to compare
@igorbenav igorbenav released this 12 Nov 05:00
· 245 commits to main since this release
0fd3f20

0.5.0 Summary

 🚀Features

  • rate_limiter dependency created 🛑
  • cache now supports pattern_to_invalidate_extra 🏬
  • logger file created to handle logging (used with rate_limiter) 🐛

📝Docs

0. New Tier and Rate Limit models

diagram
To allow fully customizable tier creation and rate limiting, new models, schemas and crud objects were created.

1. Rate Limiting

To limit how many times a user can make a request in a certain interval of time (very useful to create subscription plans or just to protect your API against DDOS), you may just use the rate_limiter dependency:

from fastapi import Depends

from app.api.dependencies import rate_limiter
from app.core import queue
from app.schemas.job import Job

@router.post("/task", response_model=Job, status_code=201, dependencies=[Depends(rate_limiter)])
async def create_task(message: str):
    job = await queue.pool.enqueue_job("sample_background_task", message)
    return {"id": job.job_id}

By default, if no token is passed in the header (that is - the user is not authenticated), the user will be limited by his IP address with the default limit (how many times the user can make this request every period) and period (time in seconds) defined in .env.

Even though this is useful, real power comes from creating tiers (categories of users) and standard rate_limits (limits and periods defined for specific paths - that is - endpoints) for these tiers.

All of the tier and rate_limit models, schemas, and endpoints are already created in the respective folders (and usable only by superusers). You may use the create_tier script to create the first tier (it uses the .env variable TIER_NAME, which is all you need to create a tier) or just use the api:

Here I'll create a free tier:

passing name = free to api request body

And a pro tier:

passing name = pro to api request body

Then I'll associate a rate_limit for the path api/v1/tasks/task for each of them, I'll associate a rate limit for the path api/v1/tasks/task.

1 request every hour (3600 seconds) for the free tier:

passing path=api/v1/tasks/task, limit=1, period=3600, name=api_v1_tasks:1:3600 to free tier rate limit

10 requests every hour for the pro tier:

passing path=api/v1/tasks/task, limit=10, period=3600, name=api_v1_tasks:10:3600 to pro tier rate limit

Now let's read all the tiers available (GET api/v1/tiers):

{
  "data": [
    {
      "name": "free",
      "id": 1,
      "created_at": "2023-11-11T05:57:25.420360"
    },
    {
      "name": "pro",
      "id": 2,
      "created_at": "2023-11-12T00:40:00.759847"
    }
  ],
  "total_count": 2,
  "has_more": false,
  "page": 1,
  "items_per_page": 10
}

And read the rate_limits for the pro tier to ensure it's working (GET api/v1/tier/pro/rate_limits):

{
  "data": [
    {
      "path": "api_v1_tasks_task",
      "limit": 10,
      "period": 3600,
      "id": 1,
      "tier_id": 2,
      "name": "api_v1_tasks:10:3600"
    }
  ],
  "total_count": 1,
  "has_more": false,
  "page": 1,
  "items_per_page": 10
}

Now, whenever an authenticated user makes a POST request to the api/v1/tasks/task, they'll use the quota that is defined by their tier.
You may check this getting the token from the api/v1/login endpoint, then passing it in the request header:

curl -X POST 'http://127.0.0.1:8000/api/v1/tasks/task?message=test' \
-H 'Authorization: Bearer <your-token-here>'

Warning
Since the rate_limiter dependency uses the get_optional_user dependency instead of get_current_user, it will not require authentication to be used, but will behave accordingly if the user is authenticated (and token is passed in header). If you want to ensure authentication, also use get_current_user if you need.

To change a user's tier, you may just use the PATCH api/v1/user/{username}/tier endpoint.
Note that for flexibility (since this is a boilerplate), it's not necessary to previously inform a tier_id to create a user, but you probably should set every user to a certain tier (let's say free) once they are created.

Warning
If a user does not have a tier or the tier does not have a defined rate limit for the path and the token is still passed to the request, the default limit and period will be used, this will be saved in app/logs.

2. Cache Pattern Invalidation

Let's assume we have an endpoint with a paginated response, such as:

@router.get("/{username}/posts", response_model=PaginatedListResponse[PostRead])
@cache(
    key_prefix="{username}_posts:page_{page}:items_per_page:{items_per_page}", 
    resource_id_name="username",
    expiration=60
)
async def read_posts(
    request: Request,
    username: str,
    db: Annotated[AsyncSession, Depends(async_get_db)],
    page: int = 1,
    items_per_page: int = 10
):
    db_user = await crud_users.get(db=db, schema_to_select=UserRead, username=username, is_deleted=False)
    if not db_user:
        raise HTTPException(status_code=404, detail="User not found")

    posts_data = await crud_posts.get_multi(
        db=db,
        offset=compute_offset(page, items_per_page),
        limit=items_per_page,
        schema_to_select=PostRead,
        created_by_user_id=db_user["id"],
        is_deleted=False
    )

    return paginated_response(
        crud_data=posts_data, 
        page=page, 
        items_per_page=items_per_page
    )

Just passing to_invalidate_extra will not work to invalidate this cache, since the key will change based on the page and items_per_page values.
To overcome this we may use the pattern_to_invalidate_extra parameter:

@router.patch("/{username}/post/{id}")
@cache(
    "{username}_post_cache", 
    resource_id_name="id", 
    pattern_to_invalidate_extra=["{username}_posts:*"]
)
async def patch_post(
    request: Request,
    username: str,
    id: int,
    values: PostUpdate,
    current_user: Annotated[UserRead, Depends(get_current_user)],
    db: Annotated[AsyncSession, Depends(async_get_db)]
):
...

Now it will invalidate all caches with a key that matches the pattern "{username}_posts:*, which will work for the paginated responses.

Warning
Using pattern_to_invalidate_extra can be resource-intensive on large datasets. Use it judiciously and consider the potential impact on Redis performance. Be cautious with patterns that could match a large number of keys, as deleting many keys simultaneously may impact the performance of the Redis server.

🚚Migration

  • Run alembic migrations to create the new models/relationships
  • To use rate limiting, you may just add the dependency as documented
  • previous cache usage will continue working, but now the option to pattern_to_invalidate_extra will also allow you to invalidate paginated responses cache

Warning
What's retrieved from the get and get multi methods is no longer a sqlalchemy.engine.row.Row, is a python dict instead. Attributes should be accessed with object["attribute_name"] instead of object.attribute_name

 🔎Bug fixes

  • custom client side cache expiration now working
  • now it's possible to invalidate cached data of paginated responses

What's Changed

Full Changelog: v0.4.1...v0.5.0