From c333afc278a30dd694e058788492f5e2c69274d1 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:59:10 -0700 Subject: [PATCH 1/2] fix(django): close automatically opened client after send failures --- CHANGELOG.md | 8 ++++ postmark/django/backend.py | 28 +++++++------- tests/django_backend/test_backend.py | 55 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 13 deletions(-) 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() From 09bfb02d972632e8c5442dd055933d86647e83b8 Mon Sep 17 00:00:00 2001 From: Greg Svoboda <13701780+gregsvo@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:07:34 -0400 Subject: [PATCH 2/2] fix(django): keep the send error if closing the client also fails If client.close() raised while cleaning up after a failed send, the close error replaced the original one for the caller and for on_exception. Log the close error and keep the send error; a close error after a successful send is still raised, as before. --- postmark/django/backend.py | 15 ++++++++- tests/django_backend/test_backend.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/postmark/django/backend.py b/postmark/django/backend.py index 5c512c4..5fb1d94 100644 --- a/postmark/django/backend.py +++ b/postmark/django/backend.py @@ -79,6 +79,7 @@ def send_messages(self, email_messages) -> int: sent_count = 0 try: client_created = self.open() + send_failed = True try: for start in range(0, len(emails), _BATCH_LIMIT): chunk = emails[start : start + _BATCH_LIMIT] @@ -91,9 +92,21 @@ def send_messages(self, email_messages) -> int: sent_count += len(responses) - len(failures) if failures: self._raise_for_failures(failures) + send_failed = False finally: if client_created: - self.close() + try: + self.close() + except Exception: + # A cleanup error must not hide the send error that is + # already propagating. After a successful send it is raised. + if not send_failed: + raise + logger.warning( + "postmark.django: error closing the client after a " + "failed send", + exc_info=True, + ) 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 d796189..b716e30 100644 --- a/tests/django_backend/test_backend.py +++ b/tests/django_backend/test_backend.py @@ -466,3 +466,53 @@ def fail_send(messages): assert backend.client is None finally: backend.close() + + +@pytest.mark.parametrize("fail_silently", [False, True]) +def test_close_error_after_a_failed_send_does_not_hide_the_send_error( + sync_client_factory, fake_sync_client, monkeypatch, fail_silently +): + """If cleanup itself fails, the original send error is the one reported.""" + seen = [] + + def record(sender, **kwargs): + seen.append(kwargs["exception"]) + + on_exception.connect(record) + + def fail_send(messages): + raise RuntimeError("send failed") + + def fail_close(): + raise OSError("close failed") + + monkeypatch.setattr(fake_sync_client.outbound, "send_batch", fail_send) + monkeypatch.setattr(fake_sync_client, "close", fail_close) + backend = EmailBackend(fail_silently=fail_silently) + message = EmailMessage("S", "B", "sender@example.com", ["receiver@example.com"]) + try: + if fail_silently: + assert backend.send_messages([message]) == 0 + else: + with pytest.raises(RuntimeError, match="send failed"): + backend.send_messages([message]) + + assert [type(e) for e in seen] == [RuntimeError] + assert backend.client is None + finally: + on_exception.disconnect(record) + + +def test_close_error_after_a_successful_send_is_still_raised( + sync_client_factory, fake_sync_client, monkeypatch +): + """Unchanged behaviour: a close failure after a good send is not swallowed.""" + + def fail_close(): + raise OSError("close failed") + + monkeypatch.setattr(fake_sync_client, "close", fail_close) + message = EmailMessage("S", "B", "sender@example.com", ["receiver@example.com"]) + + with pytest.raises(OSError, match="close failed"): + EmailBackend().send_messages([message])