diff --git a/CHANGELOG.md b/CHANGELOG.md index c67c8e1..f89d479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [Unreleased] + +### Fixed + +- Django backend closes clients it automatically opens when a batch send fails, while preserving caller-owned connections for reuse. + +--- + ## [0.4.0] - 2026-08-12 ### Added diff --git a/postmark/django/backend.py b/postmark/django/backend.py index 3fbb800..5c512c4 100644 --- a/postmark/django/backend.py +++ b/postmark/django/backend.py @@ -79,19 +79,21 @@ def send_messages(self, email_messages) -> int: sent_count = 0 try: client_created = self.open() - for start in range(0, len(emails), _BATCH_LIMIT): - chunk = emails[start : start + _BATCH_LIMIT] - pre_send.send_robust(self.__class__, messages=chunk) - responses = self.client.outbound.send_batch(chunk) - post_send.send_robust( - self.__class__, messages=chunk, response=responses - ) - failures = [r for r in responses if not r.success] - sent_count += len(responses) - len(failures) - if failures: - self._raise_for_failures(failures) - if client_created: - self.close() + try: + for start in range(0, len(emails), _BATCH_LIMIT): + chunk = emails[start : start + _BATCH_LIMIT] + pre_send.send_robust(self.__class__, messages=chunk) + responses = self.client.outbound.send_batch(chunk) + post_send.send_robust( + self.__class__, messages=chunk, response=responses + ) + failures = [r for r in responses if not r.success] + sent_count += len(responses) - len(failures) + if failures: + self._raise_for_failures(failures) + finally: + if client_created: + self.close() except Exception as exc: on_exception.send_robust( self.__class__, raw_messages=email_messages, exception=exc diff --git a/tests/django_backend/test_backend.py b/tests/django_backend/test_backend.py index 2831f85..d796189 100644 --- a/tests/django_backend/test_backend.py +++ b/tests/django_backend/test_backend.py @@ -411,3 +411,58 @@ def test_context_manager_reuses_and_closes_connection( assert len(fake_sync_client.outbound.calls) == 2 assert fake_sync_client.closed is True + + +@pytest.mark.parametrize("failure_mode", ["transport", "response"]) +@pytest.mark.parametrize("already_open", [False, True]) +@pytest.mark.parametrize("fail_silently", [False, True]) +def test_send_failure_closes_only_automatically_opened_client( + sync_client_factory, + fake_sync_client, + monkeypatch, + failure_mode, + already_open, + fail_silently, +): + backend = EmailBackend(fail_silently=fail_silently) + if already_open: + backend.open() + + if failure_mode == "transport": + + def fail_send(messages): + raise RuntimeError("local send failure") + + monkeypatch.setattr(fake_sync_client.outbound, "send_batch", fail_send) + error_type: type[Exception] = RuntimeError + else: + fake_sync_client.outbound.responses_queue.append( + [ + SendResponse( + To="receiver@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="example", + ErrorCode=300, + Message="local response failure", + ) + ] + ) + error_type = ValidationException + + message = EmailMessage( + "Subject", "Body", "sender@example.com", ["receiver@example.com"] + ) + try: + if fail_silently: + assert backend.send_messages([message]) == 0 + else: + with pytest.raises(error_type): + backend.send_messages([message]) + + assert fake_sync_client.closed is not already_open + if already_open: + assert backend.client is fake_sync_client + else: + assert backend.client is None + finally: + backend.close()