ENG-3301: Refactor messaging dispatchers into provider classes#8118
ENG-3301: Refactor messaging dispatchers into provider classes#8118
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
Introduce BaseMessageProviderService / BaseEmailProviderService / BaseSMSProviderService ABCs and migrate Mailgun, TwilioEmail, MailchimpTransactional, and TwilioSMS dispatchers to provider classes. AWS SES remains on the legacy dispatcher and will be migrated in a follow-up PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2091f2b to
ddbff34
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8118 +/- ##
==========================================
+ Coverage 85.23% 85.34% +0.11%
==========================================
Files 638 643 +5
Lines 42011 42110 +99
Branches 4937 4947 +10
==========================================
+ Hits 35808 35940 +132
+ Misses 5096 5063 -33
Partials 1107 1107 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SES is intentionally excluded from _resolve_provider_map() and PROVIDER_MAP since it uses the legacy dispatcher until PR2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The erasure email integration tests were asserting the old dispatcher function call pattern (3 positional args to constructor). Updated to assert constructor called once + send_email called with (to, message). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SES is always email (never SMS), so message is always EmailForActionType at this point. Mypy can't narrow the union. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_resolve_provider_map() is called directly in dispatch_message() and in tests. The static PROVIDER_MAP served no purpose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as the dynamic erasure email tests — update generic email and attentive integration tests from old 3-arg dispatcher pattern to constructor + send_email assertion pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/code-review |
There was a problem hiding this comment.
Code Review: Messaging Provider Refactor (PR #8118)
This is a clean, well-scoped refactor. The two-level ABC hierarchy (BaseMessageProviderService → BaseEmailProviderService / BaseSMSProviderService) is a logical structure, the test updates are thorough, and the decision to leave AWS SES as a legacy dispatcher with a clear follow-up note is reasonable.
Issues found
Bug (pre-existing, preserved): Two test functions in test_integration_dynamic_erasure_email.py (test_erasure_email_multiple_requests and test_erasure_email_multiple_requests_same_email_different_vendor) contain bare equality expressions instead of assert statements — they are silent no-ops and never actually validate mock call args. See inline comment.
Design concern: _resolve_provider_map() is rebuilt on every dispatch call specifically to accommodate test mocking. This is production code structured around test infrastructure. A module-level constant + standard mock.patch at the import path achieves the same test isolation without this coupling. See inline comment.
Type safety / ClassVar: provider_name: str on the base class is a bare annotation that type checkers and the runtime won't enforce. Should be ClassVar[str], and an __init__-time assertion or default would make missing definitions fail loudly at construction rather than silently in the error message. See inline comment.
KeyError vs MessageDispatchException: Across all four new provider __init__ methods, validation confirms details/secrets is truthy, then immediately subscripts the dict by key. A truthy-but-key-missing config escapes validation and raises a bare KeyError. This is pre-existing behavior from the old dispatchers but is worth fixing now that the structure makes it easy. See inline comment on MailchimpTransactionalService.
Minor test assertion inconsistency: "No identity" test cases assert mock_cls.return_value.send_email.assert_not_called() — this checks the instance method but not whether the constructor itself was called. Since these failures occur before instantiation, mock_cls.assert_not_called() is the more accurate assertion.
What's working well
- The ABC hierarchy cleanly separates email and SMS concerns.
autospec=Trueon all the mocks is a nice improvement over the rawmock.patchin the original tests.- Renaming private helpers to static methods (
_get_template_id_if_exists,_compose_mail) onTwilioEmailServiceis a better fit for their actual usage. MailgunService.send_emailcorrectly re-raisesMessageDispatchExceptionbefore the outerexcept Exceptionswallows it — a subtle correctness fix over the original.- The
_aws_ses_dispatcheris kept as a named legacy function with a clear comment, making the follow-up PR's task obvious.
🔬 Codegraph: connected (49390 nodes)
💡 Write /code-review in a comment to re-run this review.
- Add ClassVar[str] + __init_subclass__ guard for provider_name on BaseMessageProviderService - Replace _resolve_provider_map() with module-level _PROVIDER_MAP constant - Add _get_detail(), _get_secret(), _get_optional_secret() helpers to base class to prevent KeyError leaking from misconfigured providers - Convert all provider constructors to use base class helpers - Refactor all tests (unit + integration) to mock external HTTP calls instead of provider classes — exercises full dispatch → construction → send path - Add shared mock_mailgun_http fixture and helpers in messaging_fixtures.py - Fix bare equality bugs (missing assert) in integration tests - Add parametrized tests for wrong-key config validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/code-review |
There was a problem hiding this comment.
Code Review: Messaging Provider Refactor (#8118)
This is a solid structural improvement. Moving the dispatcher functions into a two-level ABC hierarchy (BaseMessageProviderService → BaseEmailProviderService / BaseSMSProviderService) makes the extension point much clearer than the previous function-map pattern, and the _PROVIDER_MAP dict is a clean replacement for _get_dispatcher_from_config_type. The test migration from @mock.patch on the internal dispatcher to requests_mock is a genuine improvement — tests now exercise the real HTTP layer rather than stopping at the boundary.
Issues Found
Test coverage regressions (most important)
Two unit tests in message_dispatch_service_test.py — test_email_dispatch_property_specific_templates_enabled_no_template and test_email_dispatch_property_specific_templates_enabled_with_template_no_property_default_request — now have zero assertions. They previously verified mock_mailgun_dispatcher.assert_not_called() to confirm that no email is dispatched when property-specific messaging is enabled but no matching template is found. After the refactor these tests pass trivially and no longer verify the intended behaviour. They need mock_mailgun_http added and assert not mock_mailgun_http.called.
Two integration tests in test_integration_dynamic_erasure_email.py (test_erasure_email_multiple_requests, test_erasure_email_multiple_requests_same_email_different_vendor) replace detailed call-argument assertions with only a POST-count check, leaving recipient and content correctness unverified. (The original assertions were also broken — missing assert — so the original tests were already not catching regressions, but the replacement is an opportunity to fix that properly.)
type: ignore on the AWS SES legacy path
The call to _aws_ses_dispatcher carries a # type: ignore[arg-type] because message is Union[EmailForActionType, str] at that point. A simple isinstance guard before the call would remove the suppression and make the code safer (see inline comment).
_get_detail / _get_secret return type annotation
Both helpers are annotated -> str but actually return whatever type is stored in the config dict. IS_EU_DOMAIN is stored as a boolean, which works at runtime with if is_eu, but the annotation is misleading and will confuse the next person adding a provider who expects a string. Consider -> Any or documenting the convention.
mailchimp_transactional_service.py unguarded response.json()[0]
Pre-existing, but now that the logic lives in an isolated class it's a good time to add a bounds check before indexing into the response list. An empty or malformed response currently raises an unhandled IndexError.
Nits
provider_name = ""would bypass the__init_subclass__enforcement (falsy empty string); a minor hardening concern.- The
TwilioSmsService.send_smselse branch raisesMessageDispatchExceptioninside atry/except TwilioRestExceptionblock — it propagates correctly, but a brief comment would help future maintainers avoid accidentally wrapping it.
Summary
The architecture is right and the approach is sound. The main ask before merging is to restore the two unit tests that lost their "not called" assertions, so the property-specific messaging guard is actually tested. The other items are lower priority.
🔬 Codegraph: connected (49390 nodes)
💡 Write /code-review in a comment to re-run this review.
- Remove messaging_config/mock_mailgun_http from "no messaging config" tests (attentive, request_runner) — these tests validate the failure path when no config exists and should not have a config in the DB. - Fix requeue call_count assertions in dynamic erasure email tests — the old code was a bare expression (call_count == 2) without assert, which silently passed. requeue_privacy_requests_after_email_send is called once per batch with a query containing all privacy requests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix _get_detail return type annotation (-> str | bool) for IS_EU_DOMAIN - Guard against empty Mailchimp Transactional response before indexing - Add isinstance guard on AWS SES path, removing type: ignore - Restore assertions on two property-specific messaging tests - Add recipient/subject content assertions to multi-request erasure tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add send_email tests for MailchimpTransactionalService (success, HTTP failure, rejected, empty response, no secrets) - Add send_email tests for TwilioEmailService (no template, with template, failure) - Extract provider tests into per-provider test files: test_mailchimp_transactional_service.py, test_twilio_email_service.py, test_twilio_sms_service.py - Keep dispatch_message integration tests and config validation in the original message_dispatch_service_test.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mailgun: generic exception (ConnectionError → MessageDispatchException) - Twilio Email: generic exception (ConnectionError → MessageDispatchException) - Twilio SMS: success path + TwilioRestException handling - Base class: __init_subclass__ guard for missing provider_name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…:ethyca/fides into ENG-3301/messaging-provider-refactor-pr1
| def __init_subclass__(cls, **kwargs: object) -> None: | ||
| super().__init_subclass__(**kwargs) | ||
| # Skip intermediate ABCs that define their own abstract methods | ||
| has_abstract = any( | ||
| getattr(v, "__isabstractmethod__", False) for v in cls.__dict__.values() | ||
| ) | ||
| if not has_abstract and not getattr(cls, "provider_name", None): | ||
| raise TypeError(f"{cls.__name__} must define 'provider_name'") | ||
|
|
||
| def __init__(self, messaging_config: MessagingConfig): | ||
| self.messaging_config = messaging_config | ||
| self.validate_config() |
There was a problem hiding this comment.
instead of overriding __init_subclass__ and having to check that the class isn't abstract etc, can't we just add an assertion in __init__ ? Something like:
| def __init_subclass__(cls, **kwargs: object) -> None: | |
| super().__init_subclass__(**kwargs) | |
| # Skip intermediate ABCs that define their own abstract methods | |
| has_abstract = any( | |
| getattr(v, "__isabstractmethod__", False) for v in cls.__dict__.values() | |
| ) | |
| if not has_abstract and not getattr(cls, "provider_name", None): | |
| raise TypeError(f"{cls.__name__} must define 'provider_name'") | |
| def __init__(self, messaging_config: MessagingConfig): | |
| self.messaging_config = messaging_config | |
| self.validate_config() | |
| def __init__(self, messaging_config: MessagingConfig): | |
| assert self.provider_name, "BaseMessageProviderService must define provider_name" | |
| self.messaging_config = messaging_config | |
| self.validate_config() |
There was a problem hiding this comment.
there's also a problem with the existing code that because it only looks at the class' __dict__, an intermediate ABC that inherits abstract methods without declaring new ones would incorrectly trigger the provider_name guard.
There was a problem hiding this comment.
Done — removed __init_subclass__ and moved the guard to __init__. Used if not ... raise TypeError instead of assert since asserts are stripped by python -O. Good catch on the intermediate ABC issue with __dict__.
| with requests_mock.Mocker() as m: | ||
| m.get(template_url, status_code=404) | ||
| m.post(send_url, json={"message": "Queued"}, status_code=200) |
There was a problem hiding this comment.
can we not use your mock_mailgun_http fixture for the tests on this file?
There was a problem hiding this comment.
Done — switched to mock_mailgun_http for all standard Mailgun tests in this file. The one exception is test_email_dispatch_mailgun_failed_email which needs a custom 403 response, so it keeps inline mocking. Also updated TestInitSubclassGuard → TestInitGuard to reflect the guard moving to __init__.
| BaseEmailProviderService, | ||
| ) | ||
|
|
||
| EMAIL_TEMPLATE_NAME = "fides" |
There was a problem hiding this comment.
both mailgun and twilio email define this, maybe we should have this be a constant in the base file and import it from both ?
There was a problem hiding this comment.
Done — moved EMAIL_TEMPLATE_NAME to base.py and imported from there in both providers.
| logger.error(f"Message failed to send. {error_message}") | ||
| raise MessageDispatchException(error_message) | ||
|
|
||
| def _get_detail(self, key: MessagingServiceDetails) -> str | bool: |
There was a problem hiding this comment.
this may be overkill, but str | bool is a bit of a weird type, given that there's only a single detail key that returns bool.... I wonder if we could use function signature overloads like
@overload
def _get_detail(self, key: Literal[MessagingServiceDetails.IS_EU_DOMAIN]) -> bool: ...
@overload
def _get_detail(self, key: MessagingServiceDetails) -> str: ...
or if this is too complicated, then maybe we could inline the bool look up in the mailgun class and then keep _get_detail as always returning str ?
There was a problem hiding this comment.
Done — inlined the IS_EU_DOMAIN lookup in MailgunService and kept _get_detail as -> str. Simpler than overloads for a single bool key. mypy passes clean on all changed files.
- Replace __init_subclass__ guard with simpler __init__ check - Use mock_mailgun_http fixture in dispatch service tests - Move shared EMAIL_TEMPLATE_NAME constant to base module - Keep _get_detail return type as str, inline IS_EU_DOMAIN bool lookup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
erosselli
left a comment
There was a problem hiding this comment.
thanks for making the changes! I tested with mailgun and got emails as normal
Covers previously untested guard clauses: - AWS SES rejects non-email message body - Unknown service type raises when not in provider map - Email provider rejects str body - SMS provider rejects EmailForActionType body Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ticket ENG-3301
PR Series
This is PR 1 of 3 in the messaging provider refactor:
messaging-provider-refactor-pr1messaging-provider-refactor-pr2-sesmessaging-service-alignmentBase branch:
mainDescription Of Changes
Refactors 4 of 5 messaging dispatchers into provider classes behind a two-level ABC hierarchy. This is a pure structural refactor — no behavioral changes. AWS SES remains on the legacy dispatcher and will be migrated in a follow-up PR (#8120).
Key changes:
BaseMessageProviderService/BaseEmailProviderService/BaseSMSProviderServiceABCsMailgunService,TwilioEmailService,MailchimpTransactionalService,TwilioSmsServiceprovider classesdispatch_message()to use a provider map instead of_get_dispatcher_from_config_type()_aws_ses_dispatcher(temporary, removed in ENG-3301: Migrate AWS SES dispatcher to provider class #8120)Code Changes
src/fides/api/service/messaging/messaging_providers/package with ABCs and 4 provider classesdispatch_message()to use_PROVIDER_MAPconstant for provider lookup_aws_ses_dispatcherfallback for SES (removed in follow-up PR)_mailgun_dispatcher,_twilio_email_dispatcher,_twilio_sms_dispatcher,_mailchimp_transactional_dispatcher,_get_dispatcher_from_config_type,validate_config,_compose_twilio_mail,_get_template_id_if_existsfunctionsSteps to Confirm
1. Server starts cleanly
Confirm no import errors from the new messaging provider modules.
2. Test Mailgun dispatch
Create config and send a test message:
Verify 200 response and email received.
3. Test Twilio Email dispatch
Verify 200 response and email received.
4. Test Twilio SMS dispatch
Verify 200 response and SMS received.
5. Error path — missing config
Delete any existing messaging config, then:
Verify a clear 400 error (not a 500/traceback).
Pre-Merge Checklist
CHANGELOG.mdupdated🤖 Generated with Claude Code