Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
**Learning:** While `pydantic.TypeAdapter(list[Model])` provides a theoretical ~65% performance improvement over list comprehensions by leveraging Rust-based batch processing, it may be rejected if the perceived value is low relative to the original implementation's simplicity, especially in template code.
**Action:** Prioritize optimizations that have a dramatic and undeniable impact on core latency or resource consumption.

## 2025-05-16 - [EventBridge] Secret Caching with SecretsProvider
**Learning:** Calling AWS Secrets Manager on every Lambda invocation adds 50-200ms of latency and increases API costs. Using the `max_age` parameter in Powertools `SecretsProvider.get` enables in-memory caching across warm starts, significantly reducing execution time.
**Action:** Always use a configurable `max_age` for secret retrieval in Lambda handlers to optimize performance and reduce cloud costs.

## 2026-06-10 - [Stream] Faster Cross-Model Validation with from_attributes
**Learning:** When validating a Pydantic model using data from another Pydantic model (e.g., transforming a `SourceItem` to a `DestinationItem`), using `Model.model_validate(other_model, from_attributes=True)` is significantly faster and more memory-efficient than `Model.model_validate(other_model.model_dump())`. It bypasses the overhead of serializing the source model into an intermediate Python dictionary.
**Action:** Use `from_attributes=True` for efficient model-to-model transformations, especially in high-throughput data processing paths like DynamoDB Streams or SQS batch processing.
14 changes: 3 additions & 11 deletions templates/agent/models.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
from typing import Any
from pydantic import Field

from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from templates.models import Entity


class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
class Item(Entity):
"""Model representing an item managed by the agent."""

id: str = Field(description="Unique identifier for the item.", min_length=1, max_length=50)
name: str = Field(description="Name of the item.", min_length=1, max_length=100)
description: str | None = Field(description="Description of the item.", default=None, max_length=500)

def dump(self, **kwargs: Any) -> dict:
"""Dump the model to a dictionary with default settings for responses."""
kwargs.setdefault("by_alias", True)
kwargs.setdefault("exclude_none", True)
return self.model_dump(**kwargs)
4 changes: 2 additions & 2 deletions templates/api/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def get_item(id: str) -> Response:
logger.error(message, exc_info=exc, extra={"itemId": id})
return JsonResponse({"message": "Internal server error"}, status_code=500)

return JsonResponse(item.dump())
return JsonResponse(item.dump_json())


@app.post("/items")
Expand All @@ -59,7 +59,7 @@ def create_item() -> Response:
logger.error("DynamoDB put_item failed", exc_info=exc, extra={"itemId": item.id})
return JsonResponse({"message": "Internal server error"}, status_code=500)

return JsonResponse(item.dump(), status_code=201)
return JsonResponse(item.dump_json(), status_code=201)


@logger.inject_lambda_context
Expand Down
17 changes: 3 additions & 14 deletions templates/api/models.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
from typing import Any
from uuid import uuid4
from pydantic import Field

from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from templates.models import Entity


class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
id: str = Field(
description="Unique item identifier", default_factory=lambda: str(uuid4()), min_length=1, max_length=50
)
class Item(Entity):
name: str = Field(description="Human-readable item name", min_length=1, max_length=100)

def dump(self, **kwargs: Any) -> str:
"""Dump the model to a JSON string with default settings for API responses."""
kwargs.setdefault("by_alias", True)
kwargs.setdefault("exclude_none", True)
return self.model_dump_json(**kwargs)
32 changes: 18 additions & 14 deletions templates/eventbridge/handler.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,45 @@
from aws_lambda_powertools import Logger, Metrics, Tracer
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.parameters import SecretsProvider
from aws_lambda_powertools.utilities.parser import event_parser
from aws_lambda_powertools.utilities.parser.models import EventBridgeModel
from aws_lambda_powertools.utilities.typing import LambdaContext
from requests import Session

from templates.eventbridge.models import ApiResponse
from templates.eventbridge.secrets import SecretManager
from templates.eventbridge.session import ApiSession
from templates.eventbridge.settings import Settings
from templates.repository import Repository

settings = Settings() # type: ignore
logger = Logger(service=settings.service_name)
tracer = Tracer(service=settings.service_name)
metrics = Metrics(namespace=settings.metrics_namespace, service=settings.service_name)
secrets_provider = SecretsProvider()

secret_manager = SecretManager(
max_retries=settings.secret_manager_max_retries,
max_age=settings.secret_cache_max_age,
)
repository = Repository(settings.table_name)
session = Session() # Use a single session for connection pooling and performance
session = ApiSession(
max_retries=settings.api_max_retries,
backoff_factor=settings.api_backoff_factor,
timeout=settings.api_timeout_seconds,
)


class Handler:
def __init__(self, secrets_provider: SecretsProvider, repository: Repository) -> None:
self._secrets_provider = secrets_provider
def __init__(self, secret_manager: SecretManager, repository: Repository) -> None:
self._secret_manager = secret_manager
self._repository = repository

@tracer.capture_method
def handle(self, event: EventBridgeModel) -> ApiResponse:
try:
token = self._secrets_provider.get(settings.secret_name)
response = session.get(
settings.api_url,
headers={"Authorization": f"Bearer {token}"},
timeout=settings.api_timeout_seconds,
)
token = self._secret_manager.get(settings.secret_name)
response = session.get(settings.api_url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
api_response = ApiResponse.model_validate_json(response.content)
self._repository.put_item(api_response.model_dump(by_alias=True, exclude_none=True))
self._repository.put_item(api_response.dump())
metrics.add_metric(name="ApiCallSuccess", unit=MetricUnit.Count, value=1)
logger.info("API call succeeded", extra={"api_message": api_response.message})
return api_response
Expand All @@ -45,7 +49,7 @@ def handle(self, event: EventBridgeModel) -> ApiResponse:
raise


handler = Handler(secrets_provider=secrets_provider, repository=repository)
handler = Handler(secret_manager=secret_manager, repository=repository)


@logger.inject_lambda_context
Expand Down
8 changes: 4 additions & 4 deletions templates/eventbridge/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from pydantic import Field

from templates.models import Entity

class ApiResponse(BaseModel, populate_by_name=True, alias_generator=to_camel):
id: str = Field(description="Unique identifier of the API response record", min_length=1, max_length=50)

class ApiResponse(Entity):
message: str = Field(description="Message returned by the external API", min_length=1, max_length=1000)
28 changes: 28 additions & 0 deletions templates/eventbridge/secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from aws_lambda_powertools.utilities.parameters import SecretsProvider
from botocore.config import Config


class SecretManager:
"""Wrapper around SecretsProvider with configurable retries and caching."""

def __init__(self, max_retries: int = 3, max_age: int = 60) -> None:
"""Initialize the SecretManager.

Args:
max_retries: Maximum number of retry attempts for AWS service calls.
max_age: Maximum age of the cached secret in seconds.
"""
self._max_age = max_age
config = Config(tcp_keepalive=True, retries={"max_attempts": max_retries, "mode": "standard"})
self._provider = SecretsProvider(boto_config=config)

def get(self, name: str) -> str:
"""Retrieve a secret by name.

Args:
name: The name of the secret to retrieve.

Returns:
The secret value as a string.
"""
return self._provider.get(name, max_age=self._max_age)
56 changes: 56 additions & 0 deletions templates/eventbridge/session.py
Comment thread
amrabed marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import Any

from requests import Response, Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry


class ApiSession:
"""Manages a configured requests Session with retries and connection pooling."""

def __init__(
self,
max_retries: int = 3,
backoff_factor: float = 0.3,
timeout: int = 10,
status_forcelist: list[int] | None = None,
pool_connections: int = 10,
pool_maxsize: int = 10,
) -> None:
"""Initialize the ApiSession.

Args:
max_retries: Maximum number of retries.
backoff_factor: Backoff factor for retries.
timeout: Preset timeout for requests in seconds.
status_forcelist: List of HTTP status codes to retry on.
pool_connections: Number of connection pools to cache.
pool_maxsize: Maximum number of connections to save in the pool.
"""
self._timeout = timeout
self._session = Session()
retry_strategy = Retry(
total=max_retries,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist or [429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=pool_connections,
pool_maxsize=pool_maxsize,
)
self._session.mount("http://", adapter)
self._session.mount("https://", adapter)

def get(self, url: str, **kwargs: Any) -> Response:
"""Perform a GET request with the preset timeout.

Args:
url: The URL to request.
**kwargs: Additional arguments passed to the session.get call.

Returns:
The Response object.
"""
kwargs.setdefault("timeout", self._timeout)
return self._session.get(url, **kwargs)
8 changes: 8 additions & 0 deletions templates/eventbridge/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
class Settings(BaseSettings, case_sensitive=False):
api_url: str = Field(description="URL of the external HTTP API to call")
api_timeout_seconds: int = Field(description="Timeout for the external API call in seconds", default=10)
api_max_retries: int = Field(description="Maximum number of retries for the external API call", default=3)
Comment thread
amrabed marked this conversation as resolved.
api_backoff_factor: float = Field(description="Backoff factor for API retries", default=0.3)

secret_name: str = Field(description="AWS Secrets Manager secret name holding the API token")
secret_cache_max_age: int = Field(description="Maximum age of the cached secret in seconds", default=60)
secret_manager_max_retries: int = Field(
description="Maximum number of retry attempts for AWS service calls", default=3
)

table_name: str = Field(description="DynamoDB table name for persisting API responses")
service_name: str = Field(description="Powertools service name used for Logger and Tracer")
metrics_namespace: str = Field(description="CloudWatch namespace for Powertools Metrics")
16 changes: 3 additions & 13 deletions templates/graphql/models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
from typing import Any
from uuid import uuid4
from pydantic import Field

from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from templates.models import Entity


class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
id: str = Field(
description="Unique item identifier", default_factory=lambda: str(uuid4()), min_length=1, max_length=50
)
class Item(Entity):
name: str = Field(description="Human-readable item name", min_length=1, max_length=100)

def dump(self, **kwargs: Any) -> dict:
kwargs.setdefault("by_alias", True)
kwargs.setdefault("exclude_none", True)
return self.model_dump(**kwargs)
32 changes: 32 additions & 0 deletions templates/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Any
from uuid import uuid4

from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel


class Object(BaseModel, populate_by_name=True, alias_generator=to_camel, from_attributes=True):
"""Base model for all data objects with common configuration and helper methods."""

def dump(self, **kwargs: Any) -> dict[str, Any]:
"""Dump the model to a dictionary with default settings (camelCase, exclude None)."""
kwargs.setdefault("by_alias", True)
kwargs.setdefault("exclude_none", True)
return self.model_dump(**kwargs)

def dump_json(self, **kwargs: Any) -> str:
"""Dump the model to a JSON string with default settings (camelCase, exclude None)."""
kwargs.setdefault("by_alias", True)
kwargs.setdefault("exclude_none", True)
return self.model_dump_json(**kwargs)


class Entity(Object):
"""Base model for entities with a unique identifier."""

id: str = Field(
description="Unique identifier for the entity.",
default_factory=lambda: str(uuid4()),
min_length=1,
max_length=50,
)
7 changes: 4 additions & 3 deletions templates/s3/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from enum import StrEnum

from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from pydantic import Field

from templates.models import Object


class EventSource(StrEnum):
s3 = "s3"


class ProcessedMessage(BaseModel, populate_by_name=True, alias_generator=to_camel):
class ProcessedMessage(Object):
bucket: str = Field(description="S3 bucket name", min_length=3, max_length=63)
key: str = Field(description="S3 object key", min_length=1, max_length=1024)
event_time: str = Field(description="ISO-8601 event timestamp")
Expand Down
11 changes: 5 additions & 6 deletions templates/sqs/models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from pydantic import Field

from templates.models import Entity

class SqsMessage(BaseModel, populate_by_name=True, alias_generator=to_camel):

class SqsMessage(Entity):
"""Model representing the expected SQS message body."""

id: str = Field(description="Unique identifier for the message.", min_length=1, max_length=50)
content: str = Field(description="The main content of the message.", min_length=1, max_length=1000)


class ProcessedItem(BaseModel, populate_by_name=True, alias_generator=to_camel):
class ProcessedItem(Entity):
"""Model representing the item to be stored in DynamoDB."""

id: str = Field(description="Unique identifier for the item (partition key).", min_length=1, max_length=50)
content: str = Field(description="The processed content.", min_length=1, max_length=1000)
status: str = Field(description="Processing status.", min_length=1, max_length=50)
9 changes: 5 additions & 4 deletions templates/stream/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel
from pydantic import Field

from templates.models import Entity

class SourceItem(BaseModel, populate_by_name=True, alias_generator=to_camel, from_attributes=True):

class SourceItem(Entity):
id: str = Field(description="Unique item identifier", min_length=1, max_length=50)
name: str | None = Field(default=None, description="Human-readable item name", min_length=1, max_length=100)


class DestinationItem(BaseModel, populate_by_name=True, alias_generator=to_camel, from_attributes=True):
class DestinationItem(Entity):
id: str = Field(description="Unique item identifier", min_length=1, max_length=50)
name: str | None = Field(default=None, description="Human-readable item name", min_length=1, max_length=100)
Loading