Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 28 additions & 13 deletions postmark/django/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,34 @@ 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()
send_failed = True
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)
send_failed = False
finally:
if client_created:
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
Expand Down
105 changes: 105 additions & 0 deletions tests/django_backend/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,108 @@ 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()


@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])
Loading