Skip to content

Commit 515c19b

Browse files
Add context manager for translating Moto exceptions (#13129)
1 parent 54de523 commit 515c19b

File tree

5 files changed

+82
-5
lines changed

5 files changed

+82
-5
lines changed

localstack-core/localstack/services/moto.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
"""
2-
This module provides tools to call moto using moto and botocore internals without going through the moto HTTP server.
2+
This module provides tools to call Moto service implementations.
33
"""
44

55
import copy
66
import sys
77
from collections.abc import Callable
8+
from contextlib import AbstractContextManager
89
from functools import lru_cache
910

1011
import moto.backends as moto_backends
1112
from moto.core.base_backend import BackendDict
12-
from moto.core.exceptions import RESTError
13+
from moto.core.exceptions import RESTError, ServiceException
1314
from rolo.router import RegexConverter
1415
from werkzeug.exceptions import NotFound
1516
from werkzeug.routing import Map, Rule
@@ -209,3 +210,38 @@ class _PartIsolatingRegexConverter(RegexConverter):
209210

210211
def __init__(self, *args, **kwargs) -> None:
211212
super().__init__(*args, **kwargs)
213+
214+
215+
class ServiceExceptionTranslator(AbstractContextManager):
216+
"""
217+
This reentrant context manager translates Moto exceptions into ASF service exceptions. This allows ASF to properly
218+
serialise and generate the correct error response.
219+
220+
This is useful when invoking Moto operations directly by importing the backend. For example:
221+
222+
from moto.ses import ses_backends
223+
224+
backend = ses_backend['000000000000']['us-east-1']
225+
226+
with ServiceExceptionTranslator():
227+
message = backend.send_raw_email(...)
228+
229+
If `send_raw_email(...)` raises any `moto.core.exceptions.ServiceException`, this context manager will transparently
230+
generate and raise a `localstack.aws.api.core.CommonServiceException`, maintaining the error code and message.
231+
232+
This only works for Moto services that are integrated with its new core AWS response serialiser.
233+
"""
234+
235+
def __enter__(self):
236+
pass
237+
238+
def __exit__(self, exc_type, exc_val, exc_tb):
239+
if exc_type is not None and issubclass(exc_type, ServiceException):
240+
raise CommonServiceException(
241+
code=exc_val.code,
242+
message=exc_val.message,
243+
)
244+
return False
245+
246+
247+
translate_service_exception = ServiceExceptionTranslator()

localstack-core/localstack/services/ses/provider.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
from localstack.aws.connect import connect_to
6161
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
6262
from localstack.http import Resource, Response
63-
from localstack.services.moto import call_moto
63+
from localstack.services.moto import call_moto, translate_service_exception
6464
from localstack.services.plugins import ServiceLifecycleHook
6565
from localstack.services.ses.models import EmailType, SentEmail, SentEmailBody
6666
from localstack.utils.aws import arns
@@ -478,7 +478,8 @@ def send_raw_email(
478478
destinations = destinations or []
479479

480480
backend = get_ses_backend(context)
481-
message = backend.send_raw_email(source, destinations, raw_data)
481+
with translate_service_exception:
482+
message = backend.send_raw_email(source, destinations, raw_data)
482483

483484
if event_destinations := backend.config_set_event_destination.get(configuration_set_name):
484485
payload = EventDestinationPayload(

tests/aws/services/ses/test_ses.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,3 +1122,13 @@ def test_send_templated_email_can_retrospect(self, create_template, aws_client):
11221122

11231123
assert requests.delete("http://localhost:4566/_aws/ses").status_code == 204
11241124
assert requests.get("http://localhost:4566/_aws/ses").json() == {"messages": []}
1125+
1126+
@markers.aws.validated
1127+
def test_send_email_raises_message_rejected(self, aws_client):
1128+
raw_message_data = "From: [email protected]\nTo: [email protected]\nSubject: test\n\nThis is the message body.\n\n"
1129+
1130+
with pytest.raises(ClientError) as exc:
1131+
aws_client.ses.send_raw_email(
1132+
Destinations=["[email protected]"], RawMessage={"Data": raw_message_data}
1133+
)
1134+
assert exc.value.response["Error"]["Code"] == "MessageRejected"

tests/aws/services/ses/test_ses.validation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,14 @@
7070
},
7171
"tests/aws/services/ses/test_ses.py::TestSES::test_trying_to_delete_event_destination_from_non_existent_configuration_set": {
7272
"last_validated_date": "2023-08-25T22:05:02+00:00"
73+
},
74+
"tests/aws/services/ses/test_ses.py::TestSESRetrospection::test_send_email_raises_message_rejected": {
75+
"last_validated_date": "2025-09-11T11:03:52+00:00",
76+
"durations_in_seconds": {
77+
"setup": 0.0,
78+
"call": 1.62,
79+
"teardown": 0.0,
80+
"total": 1.62
81+
}
7382
}
7483
}

tests/unit/test_moto.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import pytest
2+
from moto.core.exceptions import ServiceException
23

3-
from localstack.services.moto import get_dispatcher
4+
from localstack.aws.api import CommonServiceException
5+
from localstack.services.moto import ServiceExceptionTranslator, get_dispatcher
46

57

68
def test_get_dispatcher_for_path_with_optional_slashes():
@@ -11,3 +13,22 @@ def test_get_dispatcher_for_path_with_optional_slashes():
1113
def test_get_dispatcher_for_non_existing_path_raises_not_implemented():
1214
with pytest.raises(NotImplementedError):
1315
get_dispatcher("route53", "/non-existing")
16+
17+
18+
def test_service_exception_translator_context_manager():
19+
class WeirdException(ServiceException):
20+
code = "WeirdErrorCode"
21+
22+
# Ensure Moto ServiceExceptions are translated to ASF CommonServiceException
23+
with pytest.raises(CommonServiceException) as exc:
24+
with ServiceExceptionTranslator():
25+
raise WeirdException()
26+
assert exc.value.code == "WeirdErrorCode"
27+
28+
# Ensure other exceptions are not affected
29+
with pytest.raises(RuntimeError):
30+
raise RuntimeError()
31+
32+
with pytest.raises(RuntimeError):
33+
with ServiceExceptionTranslator():
34+
raise RuntimeError()

0 commit comments

Comments
 (0)