diff --git a/.jules/bolt.md b/.jules/bolt.md index e6c45a7..693d8da 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -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. diff --git a/templates/agent/models.py b/templates/agent/models.py index bf7d194..9a67a08 100644 --- a/templates/agent/models.py +++ b/templates/agent/models.py @@ -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) diff --git a/templates/api/handler.py b/templates/api/handler.py index cbe4ee6..7d036f7 100644 --- a/templates/api/handler.py +++ b/templates/api/handler.py @@ -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") @@ -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 diff --git a/templates/api/models.py b/templates/api/models.py index 39b121f..58dfcd7 100644 --- a/templates/api/models.py +++ b/templates/api/models.py @@ -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) diff --git a/templates/eventbridge/handler.py b/templates/eventbridge/handler.py index 9e59fe3..86c0fc8 100644 --- a/templates/eventbridge/handler.py +++ b/templates/eventbridge/handler.py @@ -1,12 +1,12 @@ 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 @@ -14,28 +14,32 @@ 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 @@ -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 diff --git a/templates/eventbridge/models.py b/templates/eventbridge/models.py index 1cbd57f..39d0552 100644 --- a/templates/eventbridge/models.py +++ b/templates/eventbridge/models.py @@ -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) diff --git a/templates/eventbridge/secrets.py b/templates/eventbridge/secrets.py new file mode 100644 index 0000000..5e153e5 --- /dev/null +++ b/templates/eventbridge/secrets.py @@ -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) diff --git a/templates/eventbridge/session.py b/templates/eventbridge/session.py new file mode 100644 index 0000000..efb180a --- /dev/null +++ b/templates/eventbridge/session.py @@ -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) diff --git a/templates/eventbridge/settings.py b/templates/eventbridge/settings.py index 28c5964..0803caa 100644 --- a/templates/eventbridge/settings.py +++ b/templates/eventbridge/settings.py @@ -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) + 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") diff --git a/templates/graphql/models.py b/templates/graphql/models.py index a682577..58dfcd7 100644 --- a/templates/graphql/models.py +++ b/templates/graphql/models.py @@ -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) diff --git a/templates/models.py b/templates/models.py new file mode 100644 index 0000000..5769ace --- /dev/null +++ b/templates/models.py @@ -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, + ) diff --git a/templates/s3/models.py b/templates/s3/models.py index 8f3b872..d4c2d03 100644 --- a/templates/s3/models.py +++ b/templates/s3/models.py @@ -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") diff --git a/templates/sqs/models.py b/templates/sqs/models.py index 3ce9fef..eccbbab 100644 --- a/templates/sqs/models.py +++ b/templates/sqs/models.py @@ -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) diff --git a/templates/stream/models.py b/templates/stream/models.py index bcc6d87..753b474 100644 --- a/templates/stream/models.py +++ b/templates/stream/models.py @@ -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) diff --git a/tests/eventbridge/test_handler.py b/tests/eventbridge/test_handler.py index ee7a5eb..44e50c4 100644 --- a/tests/eventbridge/test_handler.py +++ b/tests/eventbridge/test_handler.py @@ -63,12 +63,12 @@ def test_successful_invocation(mocker, lambda_context) -> None: """Token loaded, API called, ApiCallSuccess metric emitted, repository.put_item called.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") mock_metrics = mocker.patch.object(handler_module, "metrics") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "my-token" @@ -76,26 +76,25 @@ def test_successful_invocation(mocker, lambda_context) -> None: handler_module.main(_valid_event(), lambda_context) - mock_secrets.get.assert_called_once() + mock_secrets.get.assert_called_once_with("test-secret") mock_get.assert_called_once_with( mocker.ANY, headers={"Authorization": "Bearer my-token"}, - timeout=10, ) mock_repo.put_item.assert_called_once_with({"id": "abc-123", "message": "ok"}) mock_metrics.add_metric.assert_called_with(name="ApiCallSuccess", unit=mocker.ANY, value=1) def test_secret_loading_failure(mocker, lambda_context) -> None: - """SecretsProvider.get raises -> handler re-raises, ApiCallFailure emitted.""" + """SecretManager.get raises -> handler re-raises, ApiCallFailure emitted.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") mock_metrics = mocker.patch.object(handler_module, "metrics") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.side_effect = Exception("Secrets Manager unavailable") @@ -110,12 +109,12 @@ def test_api_non_2xx_response(mocker, lambda_context) -> None: """requests.get raises HTTPError on non-2xx -> handler re-raises, ApiCallFailure emitted.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") mock_metrics = mocker.patch.object(handler_module, "metrics") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "my-token" @@ -133,12 +132,12 @@ def test_api_network_exception(mocker, lambda_context) -> None: """requests.get raises ConnectionError -> handler re-raises, ApiCallFailure emitted.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") mock_metrics = mocker.patch.object(handler_module, "metrics") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "my-token" @@ -154,11 +153,11 @@ def test_invalid_eventbridge_event(mocker, lambda_context) -> None: """Missing required fields -> @event_parser raises ValidationError before handler is called.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo invalid_event = {"source": "aws.events", "detail-type": "Scheduled Event"} @@ -173,12 +172,12 @@ def test_dynamodb_write_failure(mocker, lambda_context) -> None: """repository.put_item raises -> handler re-raises, ApiCallFailure emitted.""" import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") mock_metrics = mocker.patch.object(handler_module, "metrics") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "my-token" diff --git a/tests/eventbridge/test_properties.py b/tests/eventbridge/test_properties.py index bb0a49b..c43161b 100644 --- a/tests/eventbridge/test_properties.py +++ b/tests/eventbridge/test_properties.py @@ -51,11 +51,11 @@ def stub_module_clients(mocker) -> None: def test_valid_event_shapes(mocker, source, detail_type, detail) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "test-token" @@ -101,11 +101,11 @@ def test_invalid_event_prevents_api_call(mocker, missing_key) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo valid_event = { @@ -133,17 +133,17 @@ def test_invalid_event_prevents_api_call(mocker, missing_key) -> None: mock_get.reset_mock() -# Feature: eventbridge-api-caller, Property 3: SecretClient exception propagates +# Feature: eventbridge-api-caller, Property 3: SecretManager exception propagates @given(exc=st.from_type(Exception)) @h_settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_secret_exception_propagates(mocker, exc) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.side_effect = exc @@ -179,11 +179,11 @@ def test_secret_exception_propagates(mocker, exc) -> None: def test_bearer_token_header(mocker, token) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = token @@ -211,7 +211,7 @@ def test_bearer_token_header(mocker, token) -> None: } handler_module.main(valid_event, mock_context) - mock_get.assert_called_once_with(mocker.ANY, headers={"Authorization": f"Bearer {token}"}, timeout=10) + mock_get.assert_called_once_with(mocker.ANY, headers={"Authorization": f"Bearer {token}"}) mock_get.reset_mock() mock_secrets.get.reset_mock() @@ -235,11 +235,11 @@ def test_2xx_response_parsed(status_code, message) -> None: def test_api_failure_propagates(mocker, status_code) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "test-token" @@ -335,7 +335,7 @@ def test_api_response_round_trip(message) -> None: input_dict = {"id": "test-id", "message": message} response = ApiResponse.model_validate(input_dict) - output = response.model_dump(by_alias=True) + output = response.dump() assert output == input_dict @@ -345,11 +345,11 @@ def test_api_response_round_trip(message) -> None: def test_successful_response_persisted(mocker, status) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "test-token" @@ -389,11 +389,11 @@ def test_successful_response_persisted(mocker, status) -> None: def test_dynamodb_write_failure_propagates(mocker, exc) -> None: import templates.eventbridge.handler as handler_module - mock_secrets = mocker.patch.object(handler_module, "secrets_provider") + mock_secrets = mocker.patch.object(handler_module, "secret_manager") mock_get = mocker.patch.object(handler_module.session, "get") mock_repo = mocker.patch.object(handler_module, "repository") - handler_module.handler._secrets_provider = mock_secrets + handler_module.handler._secret_manager = mock_secrets handler_module.handler._repository = mock_repo mock_secrets.get.return_value = "test-token" diff --git a/tests/stream/test_handler.py b/tests/stream/test_handler.py index 417dc2e..94532d8 100644 --- a/tests/stream/test_handler.py +++ b/tests/stream/test_handler.py @@ -126,6 +126,7 @@ def test_deserialisation_failure_reports_batch_item_failure(mock_repo, lambda_co event = _stream_event(bad_record, good_record) result = handler_module.main(event, lambda_context) + # The bad record should not call put_item because validation fails mock_repo.put_item.assert_called_once_with({"id": "ok", "name": "Good"}) assert len(result["batchItemFailures"]) == 1