From 26619827fc153f29f498c15b2f0c57a7a166b991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Fri, 24 Oct 2025 16:43:29 -0300 Subject: [PATCH 1/7] feat: celery task email client default on api container --- app/celery/tasks/emails.py | 38 +++++-------------- app/core/config.py | 10 +++-- app/emails/__init__.py | 2 +- app/emails/clients/__init__.py | 1 + .../clients/celery_task_email_client.py | 27 +++++++++++++ .../_mailpit_email_client.py | 6 +-- app/emails/services/emails_service.py | 4 +- app/main.py | 5 ++- app/users/use_cases/create_user_use_case.py | 7 ++-- docker-compose.yml | 3 ++ poetry.lock | 17 ++++++++- pyproject.toml | 1 + 12 files changed, 76 insertions(+), 45 deletions(-) create mode 100644 app/emails/clients/celery_task_email_client.py diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 8edc3d0..4f89b5b 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -1,42 +1,24 @@ -from uuid import UUID -from app.emails.exceptions.email_client_exception import EmailClientException from app.common.schemas.pagination_schema import ListFilter -from app.emails.services.emails_service import EmailService +from app.emails import Email, EmailService, get_client from app.db.session import SessionLocal from app.main import celery - -from app.core.config import get_settings from app.users.schemas.user_schema import UserInDB from app.users.services.users_service import UsersService -settings = get_settings() - @celery.task def send_reminder_email() -> None: - session = SessionLocal() - try: + service = EmailService() + + with SessionLocal() as session: users = UsersService(session).list(ListFilter(page=1, page_size=100)) for user in users.data: - EmailService().send_user_remind_email( - UserInDB.model_validate(user) - ) - finally: - session.close() + service.send_user_remind_email(UserInDB.model_validate(user)) -@celery.task( - autoretry_for=(EmailClientException,), - retry_backoff=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, - max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, - retry_jitter=False, -) -def send_welcome_email(user_id: UUID) -> None: - session = SessionLocal() - try: - user = UsersService(session).get_by_id(user_id) - if user: - EmailService().send_new_user_email(UserInDB.model_validate(user)) - finally: - session.close() +@celery.task +def send_email(serialized_email: dict) -> None: + email = Email(**serialized_email) + client = get_client() + client.send_email(email) diff --git a/app/core/config.py b/app/core/config.py index e7d324f..06a1144 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,7 +1,7 @@ import logging import secrets from functools import lru_cache -from typing import Any, Final, List, Optional, Union +from typing import Any, Final, List, Literal, Optional, Union from pydantic import AnyHttpUrl, PostgresDsn, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo @@ -25,6 +25,7 @@ class Settings(BaseSettings): PROJECT_NAME: str AUTHENTICATION_API_RATE_LIMIT: str = "5 per minute" SECURE_COOKIE: bool = True + PROCESS_TYPE: Literal["api", "worker", "beat"] # Database POSTGRES_SERVER: str @@ -49,8 +50,11 @@ class Settings(BaseSettings): # Mail SENDER_EMAIL: str = "test@test.com" - SEND_WELCOME_EMAIL_MAX_RETRIES: int = 5 - SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE: int = 5 + SEND_EMAIL_MAX_RETRIES: int = 5 + SEND_EMAIL_RETRY_BACKOFF_VALUE: int = 5 + # TODO: configure individual emails + # SEND_WELCOME_EMAIL_MAX_RETRIES: int = 5 + # SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE: int = 5 # Mailpit MAILPIT_URI: str | None = None diff --git a/app/emails/__init__.py b/app/emails/__init__.py index f59f472..d0e7c32 100644 --- a/app/emails/__init__.py +++ b/app/emails/__init__.py @@ -5,5 +5,5 @@ from ._global_state import set_client, get_client -from .clients import MailpitEmailClient, ExampleEmailClient +from .clients import MailpitEmailClient, ExampleEmailClient, CeleryTaskEmailClient from .services.emails_service import EmailService diff --git a/app/emails/clients/__init__.py b/app/emails/clients/__init__.py index bcc33a2..93837d3 100644 --- a/app/emails/clients/__init__.py +++ b/app/emails/clients/__init__.py @@ -1,2 +1,3 @@ from .mailpit_email_client import MailpitEmailClient from .example_email_client import ExampleEmailClient +from .celery_task_email_client import CeleryTaskEmailClient diff --git a/app/emails/clients/celery_task_email_client.py b/app/emails/clients/celery_task_email_client.py new file mode 100644 index 0000000..12781e5 --- /dev/null +++ b/app/emails/clients/celery_task_email_client.py @@ -0,0 +1,27 @@ +from app.common.exceptions import ExternalProviderException +from app.core.config import settings +from app.emails.clients.base import BaseEmailClient +from app.emails.schema.email import Email + + +class CeleryTaskEmailClient(BaseEmailClient): + def __init__(self) -> None: + super().__init__() + from app.celery.tasks.emails import send_email + + self.task = send_email + + def send_email(self, /, email: Email) -> None: + serialized_email = email.model_dump( + mode="json", + exclude_unset=True, + ) + + self.task.apply_async( + args=(serialized_email,), + retry_policy={ + "retry_errors": (ExternalProviderException,), + "max_retries": settings.SEND_EMAIL_MAX_RETRIES, + "interval_step": settings.SEND_EMAIL_RETRY_BACKOFF_VALUE, + }, + ) diff --git a/app/emails/clients/mailpit_email_client/_mailpit_email_client.py b/app/emails/clients/mailpit_email_client/_mailpit_email_client.py index edbf745..aef47b4 100644 --- a/app/emails/clients/mailpit_email_client/_mailpit_email_client.py +++ b/app/emails/clients/mailpit_email_client/_mailpit_email_client.py @@ -21,11 +21,7 @@ def __init__( super().__init__() self.base_url = mailpit_uri or settings.MAILPIT_URI - def send_email( - self, - /, - email: Email, - ) -> None: + def send_email(self, /, email: Email) -> None: schema = _MailpitEmailSchema.from_email(email) response = self._make_request( diff --git a/app/emails/services/emails_service.py b/app/emails/services/emails_service.py index 5130552..44314cc 100644 --- a/app/emails/services/emails_service.py +++ b/app/emails/services/emails_service.py @@ -44,7 +44,7 @@ def send_new_user_email( "Welcome", ) - return self.email_client.send_email(email) + self.email_client.send_email(email) def send_user_remind_email( self, @@ -56,4 +56,4 @@ def send_user_remind_email( "Welcome", ) - return self.email_client.send_email(email) + self.email_client.send_email(email) diff --git a/app/main.py b/app/main.py index ac2b0f4..ee78eee 100644 --- a/app/main.py +++ b/app/main.py @@ -44,7 +44,10 @@ from app import emails if settings.RUN_ENV == "local": - email_client = emails.MailpitEmailClient() + if settings.PROCESS_TYPE == "api": + email_client = emails.CeleryTaskEmailClient() + else: + email_client = emails.MailpitEmailClient() else: email_client = emails.ExampleEmailClient() emails.set_client(email_client) diff --git a/app/users/use_cases/create_user_use_case.py b/app/users/use_cases/create_user_use_case.py index 41afec2..7fd3fec 100644 --- a/app/users/use_cases/create_user_use_case.py +++ b/app/users/use_cases/create_user_use_case.py @@ -1,8 +1,9 @@ +from fastapi import status from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session -from fastapi import status from app.auth.utils import security +from app.emails.services.emails_service import EmailService from app.users.schemas.user_schema import ( CreateUserRequest, UserCreate, @@ -16,8 +17,6 @@ def __init__(self, session: Session): self.session = session def execute(self, create_user_request: CreateUserRequest) -> UserResponse: - from app.celery.tasks.emails import send_welcome_email - users_service = UsersService(self.session) if users_service.get_by_email(create_user_request.email): raise HTTPException( @@ -34,7 +33,7 @@ def execute(self, create_user_request: CreateUserRequest) -> UserResponse: ) ) - send_welcome_email.delay(created_user.id) # type: ignore + EmailService().send_new_user_email(created_user) return UserResponse( id=created_user.id, diff --git a/docker-compose.yml b/docker-compose.yml index a783888..81f17c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: env_file: - .env environment: + - PROCESS_TYPE=api - PYTHONUNBUFFERED=1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-test} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-test} @@ -54,6 +55,7 @@ services: env_file: - .env environment: + - PROCESS_TYPE=worker - AWS_DEFAULT_REGION=us-east-1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-test} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-test} @@ -75,6 +77,7 @@ services: env_file: - .env environment: + - PROCESS_TYPE=beat - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-test} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-test} - AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL:-http://localstack:4566} diff --git a/poetry.lock b/poetry.lock index 0a3166a..7897a38 100644 --- a/poetry.lock +++ b/poetry.lock @@ -262,6 +262,21 @@ yaml = ["kombu[yaml]"] zookeeper = ["kazoo (>=1.3.1)"] zstd = ["zstandard (==0.23.0)"] +[[package]] +name = "celery-types" +version = "0.23.0" +description = "Type stubs for Celery and its related packages" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["dev"] +files = [ + {file = "celery_types-0.23.0-py3-none-any.whl", hash = "sha256:0cc495b8d7729891b7e070d0ec8d4906d2373209656a6e8b8276fe1ed306af9a"}, + {file = "celery_types-0.23.0.tar.gz", hash = "sha256:402ed0555aea3cd5e1e6248f4632e4f18eec8edb2435173f9e6dc08449fa101e"}, +] + +[package.dependencies] +typing-extensions = ">=4.9.0,<5.0.0" + [[package]] name = "certifi" version = "2025.6.15" @@ -2217,4 +2232,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "0bfc264a8260ea22a4e31c10f4f9eeb1267fa78df597a53045bcea6afb712d36" +content-hash = "c6885ac7a1c6e87f899564dcdf4d91b7d8f8845e324b7044b1160fe02ed10f3f" diff --git a/pyproject.toml b/pyproject.toml index 5c9edc0..338feae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ pytest-cov = "^5.0.0" ruff = "^0.9.9" types-pytz = "^2025.2.0.20250809" types-requests = "^2.32.4.20250913" +celery-types = "^0.23.0" [build-system] requires = ["poetry-core"] From e541d46e98345af3fbcb7847d7ce18e88ff587d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Mon, 27 Oct 2025 15:47:17 -0300 Subject: [PATCH 2/7] feat: per email retry configuration --- app/celery/tasks/emails.py | 26 +++++++++++++++-- app/core/config.py | 28 ++++++++++++------- .../clients/celery_task_email_client.py | 16 ++++------- app/emails/schema/email.py | 11 +++++++- app/emails/services/emails_service.py | 8 +++++- 5 files changed, 63 insertions(+), 26 deletions(-) diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 4f89b5b..944a51f 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -1,3 +1,8 @@ +from celery import Task + +from app.common.exceptions.external_provider_exception import ( + ExternalProviderException, +) from app.common.schemas.pagination_schema import ListFilter from app.emails import Email, EmailService, get_client from app.db.session import SessionLocal @@ -17,8 +22,23 @@ def send_reminder_email() -> None: service.send_user_remind_email(UserInDB.model_validate(user)) -@celery.task -def send_email(serialized_email: dict) -> None: +@celery.task(bind=True) +def send_email(self: Task, serialized_email: dict) -> None: email = Email(**serialized_email) client = get_client() - client.send_email(email) + + try: + client.send_email(email) + except ExternalProviderException as exc: + if email.context: + countdown_in_seconds = email.context.backoff_value_in_seconds * ( + 2**self.request.retries + ) + + raise self.retry( + exc=exc, + max_retries=email.context.max_retries, + countdown=countdown_in_seconds, + ) + + raise diff --git a/app/core/config.py b/app/core/config.py index 06a1144..6669d58 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,7 +3,14 @@ from functools import lru_cache from typing import Any, Final, List, Literal, Optional, Union -from pydantic import AnyHttpUrl, PostgresDsn, field_validator, model_validator +from pydantic import ( + AnyHttpUrl, + PostgresDsn, + NonNegativeInt, + EmailStr, + field_validator, + model_validator, +) from pydantic_core.core_schema import ValidationInfo from pydantic_settings import BaseSettings, SettingsConfigDict @@ -16,7 +23,8 @@ class Settings(BaseSettings): frozen=True, ) # APP - RUN_ENV: str = "local" + RUN_ENV: Literal["local", "develop", "staging", "production"] = "local" + PROCESS_TYPE: Literal["api", "worker", "beat"] API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) SERVER_NAME: str @@ -25,7 +33,6 @@ class Settings(BaseSettings): PROJECT_NAME: str AUTHENTICATION_API_RATE_LIMIT: str = "5 per minute" SECURE_COOKIE: bool = True - PROCESS_TYPE: Literal["api", "worker", "beat"] # Database POSTGRES_SERVER: str @@ -48,13 +55,14 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 - # Mail - SENDER_EMAIL: str = "test@test.com" - SEND_EMAIL_MAX_RETRIES: int = 5 - SEND_EMAIL_RETRY_BACKOFF_VALUE: int = 5 - # TODO: configure individual emails - # SEND_WELCOME_EMAIL_MAX_RETRIES: int = 5 - # SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE: int = 5 + # Email - general + SENDER_EMAIL: EmailStr = "test@test.com" + SEND_EMAIL_MAX_RETRIES: NonNegativeInt = 5 + SEND_EMAIL_RETRY_BACKOFF_VALUE: NonNegativeInt = 5 + + # Email - specific + SEND_WELCOME_EMAIL_MAX_RETRIES: NonNegativeInt = 5 + SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE: NonNegativeInt = 5 # Mailpit MAILPIT_URI: str | None = None diff --git a/app/emails/clients/celery_task_email_client.py b/app/emails/clients/celery_task_email_client.py index 12781e5..ce534cc 100644 --- a/app/emails/clients/celery_task_email_client.py +++ b/app/emails/clients/celery_task_email_client.py @@ -1,7 +1,5 @@ -from app.common.exceptions import ExternalProviderException -from app.core.config import settings from app.emails.clients.base import BaseEmailClient -from app.emails.schema.email import Email +from app.emails.schema.email import Email, EmailContext class CeleryTaskEmailClient(BaseEmailClient): @@ -12,16 +10,12 @@ def __init__(self) -> None: self.task = send_email def send_email(self, /, email: Email) -> None: + if not email.context: + email.context = EmailContext() + serialized_email = email.model_dump( mode="json", exclude_unset=True, ) - self.task.apply_async( - args=(serialized_email,), - retry_policy={ - "retry_errors": (ExternalProviderException,), - "max_retries": settings.SEND_EMAIL_MAX_RETRIES, - "interval_step": settings.SEND_EMAIL_RETRY_BACKOFF_VALUE, - }, - ) + self.task.delay(serialized_email) diff --git a/app/emails/schema/email.py b/app/emails/schema/email.py index b2b83f3..68cd857 100644 --- a/app/emails/schema/email.py +++ b/app/emails/schema/email.py @@ -1,8 +1,15 @@ -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, NonNegativeInt from app.core.config import settings +class EmailContext(BaseModel): + max_retries: NonNegativeInt = settings.SEND_EMAIL_MAX_RETRIES + backoff_value_in_seconds: NonNegativeInt = ( + settings.SEND_EMAIL_RETRY_BACKOFF_VALUE + ) + + class Email(BaseModel): from_email: EmailStr = settings.SENDER_EMAIL from_name: str = "FastApi" @@ -18,3 +25,5 @@ class Email(BaseModel): "MIME-Version": "1.0", "Content-Type": "text/html", } + + context: EmailContext | None = None diff --git a/app/emails/services/emails_service.py b/app/emails/services/emails_service.py index 44314cc..c0eb16b 100644 --- a/app/emails/services/emails_service.py +++ b/app/emails/services/emails_service.py @@ -1,9 +1,10 @@ from enum import Enum from string import Template +from app.core.config import settings from app.users.schemas.user_schema import UserInDB from app.emails.clients.base import BaseEmailClient -from app.emails.schema.email import Email +from app.emails.schema.email import Email, EmailContext from app.emails._global_state import get_client @@ -44,6 +45,11 @@ def send_new_user_email( "Welcome", ) + email.context = EmailContext( + max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, + backoff_value_in_seconds=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, + ) + self.email_client.send_email(email) def send_user_remind_email( From 37a96a89555718d0e3043cb815bfc803dd52c0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Mon, 27 Oct 2025 16:10:09 -0300 Subject: [PATCH 3/7] feat: improve request client error message --- app/common/clients/base_request_client.py | 33 +++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/app/common/clients/base_request_client.py b/app/common/clients/base_request_client.py index aa55609..2c3eeb2 100644 --- a/app/common/clients/base_request_client.py +++ b/app/common/clients/base_request_client.py @@ -22,13 +22,14 @@ def _make_request( params: dict | None = None, auth: tuple[str, str] | None = None, json: dict | None = None, + error_message: str | None = None, ) -> requests.Response | None: + url = f"{self.base_url}{endpoint}" if self.base_url else endpoint + try: response = requests.request( method=method, - url=( - f"{self.base_url}{endpoint}" if self.base_url else endpoint - ), + url=url, headers=headers, data=data, files=files, @@ -40,12 +41,22 @@ def _make_request( response.raise_for_status() return response - except requests.exceptions.RequestException as error: - logger.error(str(endpoint)) - logger.error(str(error)) - logger.error(str(data)) - logger.error(str(params)) - logger.error( - f"response: {error.response.text if error.response else None}" - ) + except requests.exceptions.RequestException as exc: + message = error_message or self._get_error_message(exc, url) + logger.error(message) return None + + def _get_error_message( + self, + exc: requests.exceptions.RequestException, + url: str, + ) -> str: + msg_data = { + "url": url, + "exception": exc, + } + + if exc.response: + msg_data["response"] = exc.response.text + + return f"{self.__class__}: request failed. Data: {msg_data}" From 015d1e028515d2497c28749524506e912e89d83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Mon, 27 Oct 2025 18:03:48 -0300 Subject: [PATCH 4/7] feat: add posilibity to pass error message in email context --- app/celery/tasks/emails.py | 2 +- .../clients/mailpit_email_client/_mailpit_email_client.py | 5 ++++- app/emails/schema/email.py | 3 ++- app/emails/services/emails_service.py | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 944a51f..3f1f9f6 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -31,7 +31,7 @@ def send_email(self: Task, serialized_email: dict) -> None: client.send_email(email) except ExternalProviderException as exc: if email.context: - countdown_in_seconds = email.context.backoff_value_in_seconds * ( + countdown_in_seconds = email.context.backoff_in_seconds * ( 2**self.request.retries ) diff --git a/app/emails/clients/mailpit_email_client/_mailpit_email_client.py b/app/emails/clients/mailpit_email_client/_mailpit_email_client.py index aef47b4..dfd46e6 100644 --- a/app/emails/clients/mailpit_email_client/_mailpit_email_client.py +++ b/app/emails/clients/mailpit_email_client/_mailpit_email_client.py @@ -31,5 +31,8 @@ def send_email(self, /, email: Email) -> None: ) if not response: - message = "Email not sent, see logs for details." + if email.context and email.context.error_message: + message = email.context.error_message + else: + message = "Email not sent, see logs for details." raise ExternalProviderException(message) diff --git a/app/emails/schema/email.py b/app/emails/schema/email.py index 68cd857..2cd7ea8 100644 --- a/app/emails/schema/email.py +++ b/app/emails/schema/email.py @@ -5,9 +5,10 @@ class EmailContext(BaseModel): max_retries: NonNegativeInt = settings.SEND_EMAIL_MAX_RETRIES - backoff_value_in_seconds: NonNegativeInt = ( + backoff_in_seconds: NonNegativeInt = ( settings.SEND_EMAIL_RETRY_BACKOFF_VALUE ) + error_message: str | None = None class Email(BaseModel): diff --git a/app/emails/services/emails_service.py b/app/emails/services/emails_service.py index c0eb16b..3352733 100644 --- a/app/emails/services/emails_service.py +++ b/app/emails/services/emails_service.py @@ -47,7 +47,8 @@ def send_new_user_email( email.context = EmailContext( max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, - backoff_value_in_seconds=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, + backoff_in_seconds=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, + error_message=f"Sending new user email to user {user.id} failed", ) self.email_client.send_email(email) From 371ca4a7b6818b815b703c3e1de394413555ca86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Mon, 27 Oct 2025 18:06:50 -0300 Subject: [PATCH 5/7] refactor: minor --- app/common/clients/base_request_client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/common/clients/base_request_client.py b/app/common/clients/base_request_client.py index 2c3eeb2..9f90207 100644 --- a/app/common/clients/base_request_client.py +++ b/app/common/clients/base_request_client.py @@ -51,10 +51,7 @@ def _get_error_message( exc: requests.exceptions.RequestException, url: str, ) -> str: - msg_data = { - "url": url, - "exception": exc, - } + msg_data = {"url": url, "exception": exc} if exc.response: msg_data["response"] = exc.response.text From 897e0605847af6463deb4357a77ab8f4f97b1ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Mon, 27 Oct 2025 18:13:21 -0300 Subject: [PATCH 6/7] refactor: email client selection code --- app/main.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index ee78eee..6627d78 100644 --- a/app/main.py +++ b/app/main.py @@ -43,11 +43,10 @@ from app import emails -if settings.RUN_ENV == "local": - if settings.PROCESS_TYPE == "api": - email_client = emails.CeleryTaskEmailClient() - else: - email_client = emails.MailpitEmailClient() +if settings.PROCESS_TYPE == "api": + email_client = emails.CeleryTaskEmailClient() +elif settings.RUN_ENV == "local": + email_client = emails.MailpitEmailClient() else: email_client = emails.ExampleEmailClient() emails.set_client(email_client) From 3cb1cadcf4fbcca9a031353cd1fcf4a946e68056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Troncaro?= Date: Wed, 5 Nov 2025 19:47:30 -0300 Subject: [PATCH 7/7] refactor: for legibility --- app/celery/tasks/emails.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 3f1f9f6..fab4b72 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -1,17 +1,20 @@ +from typing import Final + from celery import Task -from app.common.exceptions.external_provider_exception import ( - ExternalProviderException, -) +from app.common.exceptions import ExternalProviderException from app.common.schemas.pagination_schema import ListFilter -from app.emails import Email, EmailService, get_client from app.db.session import SessionLocal +from app.emails import Email, EmailService, get_client from app.main import celery from app.users.schemas.user_schema import UserInDB from app.users.services.users_service import UsersService +BACKOFF_EXPONENTIAL_GROWTH_BASE: Final[int] = 2 + + @celery.task def send_reminder_email() -> None: service = EmailService() @@ -30,15 +33,14 @@ def send_email(self: Task, serialized_email: dict) -> None: try: client.send_email(email) except ExternalProviderException as exc: - if email.context: - countdown_in_seconds = email.context.backoff_in_seconds * ( - 2**self.request.retries - ) - - raise self.retry( - exc=exc, - max_retries=email.context.max_retries, - countdown=countdown_in_seconds, - ) - - raise + if not email.context: + raise + + multiplicator = BACKOFF_EXPONENTIAL_GROWTH_BASE**self.request.retries + countdown_in_seconds = email.context.backoff_in_seconds * multiplicator + + raise self.retry( + exc=exc, + max_retries=email.context.max_retries, + countdown=countdown_in_seconds, + )