diff --git a/.markdownlint.json b/.markdownlint.json index 4c98f54..c0add5b 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,6 @@ { "MD013": false, + "MD024": { "siblings_only": true }, "MD033": false, "MD041": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8eb8..4543713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-07-29 + +### Changed + +- **BREAKING:** send failures now raise `CustomerIOSendError` by default instead of + returning a `SendResult` with `ok=False`. Previously a failed send was + indistinguishable from a successful one unless the caller remembered to check + `result.ok` — and Python has no `must_use`, so nothing prompted them to. Pass + `raise_on_error=False` to the constructor or `from_env()` to restore the previous + behaviour for batch sends. +- `_build_error_result` no longer calls `logger.exception`. That log fired from a + frame with no recipient, subject, or template context, so it was unactionable + while making the failure look handled. Failures are now logged at `DEBUG` with + `exc_info`, and reported to the caller instead — either as a raised exception or, + in `raise_on_error=False` mode, as a `logger.warning` from the send method with + full context. +- Retry warnings now include the recipient and the template or subject being sent. + +### Added + +- `CustomerIOSendError`, exported from `altissimo.customerio`. Carries + `status_code` and the failed `result`, and chains the originating SDK exception + as `__cause__`. +- `SendResult.raise_for_status(context?)` — mirrors + `requests.Response.raise_for_status`; a no-op on success. +- `SendResult.exception` field preserving the originating SDK exception so callers + can log or re-raise it with their own context. +- `raise_on_error` parameter on `CustomerIOClient(...)` and + `CustomerIOClient.from_env(...)` (default `True`). +- README "Error Handling" section covering both modes. + ## [0.1.0] - 2026-06-29 ### Added @@ -24,4 +55,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pre-commit hooks configuration - GitHub Actions CI workflow (lint, test matrix 3.11–3.13, build verification) +[0.2.0]: https://github.com/altissimo-hq/customerio-python/releases/tag/v0.2.0 [0.1.0]: https://github.com/altissimo-hq/customerio-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index ad4ae47..379f417 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Customer.io transactional email and messaging client for Altissimo Python projec - 📐 **Typed** — full type annotations with `py.typed` PEP 561 marker - ✅ **Consistent return type** — all methods return a `SendResult` dataclass - 🔄 **Retry with backoff** — configurable retry for transient failures +- 🔔 **Fails loudly** — send failures raise `CustomerIOSendError` by default; opt out per client for batch sends ## Requirements @@ -58,10 +59,51 @@ result = client.send_html( reply_to="support@example.com", ) -assert result.ok print(result.delivery_id) ``` +## Error Handling + +By default a failed send raises `CustomerIOSendError`. The originating SDK +exception is preserved as `__cause__`, and the full `SendResult` is attached +as `.result`: + +```python +from altissimo.customerio import CustomerIOClient, CustomerIOSendError + +try: + result = client.send_html(to="user@example.com", subject="Hi", html="
Hi
") +except CustomerIOSendError as exc: + logger.error("Reset email failed (status=%s): %s", exc.status_code, exc) +else: + logger.info("Reset email sent: delivery_id=%s", result.delivery_id) +``` + +For batch sends where one bad recipient should not abort the run, opt out and +check `result.ok` yourself: + +```python +client = CustomerIOClient.from_env(raise_on_error=False) + +for user in users: + result = client.send_email(to=user.email, transactional_message_id="3") + if not result.ok: + failures.append((user.email, result.status_code, result.error)) +``` + +`SendResult.raise_for_status()` converts a result to an exception on demand +(mirroring `requests.Response.raise_for_status`), which is handy when you want +swallow semantics in one place and raise semantics in another: + +```python +client.send_html(to=..., subject=..., html=...).raise_for_status() +``` + +> **Note:** the library does not log send failures above `DEBUG` when +> `raise_on_error=True` — it has no recipient or template context worth +> logging from that frame, and a log there would make the failure look +> handled. Reporting is the caller's job. + ## Configuration ### Environment Variables @@ -80,6 +122,7 @@ client = CustomerIOClient( default_from="noreply@lived.com", # default sender max_retries=3, # retry transient failures retry_delay=1.0, # base delay (seconds) + raise_on_error=True, # raise CustomerIOSendError on failure (default) ) ``` @@ -89,8 +132,8 @@ client = CustomerIOClient( | Method | Description | |---|---| -| `CustomerIOClient(app_api_key, region?, default_from?)` | Create a client with an explicit API key | -| `CustomerIOClient.from_env(env_var?, region?, default_from?)` | Create a client from an environment variable | +| `CustomerIOClient(app_api_key, region?, default_from?, raise_on_error?)` | Create a client with an explicit API key | +| `CustomerIOClient.from_env(env_var?, region?, default_from?, raise_on_error?)` | Create a client from an environment variable | | `send_email(to, transactional_message_id, message_data?, ...)` | Send a template-based transactional email | | `send_text(to, subject, body, from_email?, reply_to?, ...)` | Send a plain-text email (inline content) | | `send_html(to, subject, html, from_email?, reply_to?, ...)` | Send an HTML email (inline content) | @@ -104,6 +147,19 @@ client = CustomerIOClient( | `status_code` | `int` | HTTP status code (0 on exception) | | `body` | `dict[str, Any]` | Response body | | `error` | `str \| None` | Error message on failure | +| `exception` | `Exception \| None` | Originating SDK exception on failure | + +| Method | Description | +|---|---| +| `raise_for_status(context?)` | Raise `CustomerIOSendError` if `ok` is `False`; no-op otherwise | + +### Exceptions + +| Exception | Raised when | +|---|---| +| `CustomerIOError` | Base class for all library errors | +| `CustomerIOImportError` | The `customerio` SDK is not installed | +| `CustomerIOSendError` | A send failed (carries `status_code` and `result`) | ## Architecture @@ -111,7 +167,7 @@ client = CustomerIOClient( altissimo.customerio ├── __init__.py # Public API surface ├── client.py # CustomerIOClient with lazy SDK initialization -├── exceptions.py # CustomerIOError, CustomerIOImportError +├── exceptions.py # CustomerIOError, CustomerIOImportError, CustomerIOSendError ├── models.py # EmailAddress, SendResult dataclasses └── py.typed # PEP 561 marker ``` diff --git a/pyproject.toml b/pyproject.toml index 8efeedd..dbb9fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "altissimo-customerio" -version = "0.1.0" +version = "0.2.0" description = "Customer.io transactional email and messaging client for Altissimo projects" readme = "README.md" license = "Apache-2.0" diff --git a/src/altissimo/customerio/__init__.py b/src/altissimo/customerio/__init__.py index f882e9e..37f041c 100644 --- a/src/altissimo/customerio/__init__.py +++ b/src/altissimo/customerio/__init__.py @@ -5,18 +5,19 @@ from importlib.metadata import PackageNotFoundError, version from .client import CustomerIOClient, EmailAddressLike, EmailRecipients -from .exceptions import CustomerIOError, CustomerIOImportError +from .exceptions import CustomerIOError, CustomerIOImportError, CustomerIOSendError from .models import EmailAddress, SendResult try: __version__ = version("altissimo-customerio") except PackageNotFoundError: - __version__ = "0.1.0" + __version__ = "0.2.0" __all__ = [ "CustomerIOClient", "CustomerIOError", "CustomerIOImportError", + "CustomerIOSendError", "EmailAddress", "EmailAddressLike", "EmailRecipients", diff --git a/src/altissimo/customerio/client.py b/src/altissimo/customerio/client.py index c4e5270..d42be4f 100644 --- a/src/altissimo/customerio/client.py +++ b/src/altissimo/customerio/client.py @@ -98,6 +98,12 @@ class CustomerIOClient: Set to ``0`` (default) to disable retries. retry_delay: Base delay in seconds between retries. Actual delay uses exponential backoff with jitter. + raise_on_error: If ``True`` (default), a failed send raises + :class:`CustomerIOSendError` instead of returning a + ``SendResult`` with ``ok=False``. Set to ``False`` for batch + sends where you want to inspect results and continue past a + failed recipient — but then you are responsible for checking + ``result.ok``, because nothing else will. """ def __init__( @@ -108,12 +114,14 @@ def __init__( *, max_retries: int = 0, retry_delay: float = 1.0, + raise_on_error: bool = True, ) -> None: self._app_api_key = app_api_key self._region_str = region self._default_from = default_from self._max_retries = max_retries self._retry_delay = retry_delay + self._raise_on_error = raise_on_error self._client: APIClient | None = None @classmethod @@ -125,6 +133,7 @@ def from_env( default_from: EmailAddressLike | None = None, max_retries: int = 0, retry_delay: float = 1.0, + raise_on_error: bool = True, ) -> CustomerIOClient: """Create a client using an API key from an environment variable. @@ -136,6 +145,7 @@ def from_env( default_from: Default sender email address. max_retries: Maximum number of retry attempts for transient errors. retry_delay: Base delay in seconds between retries. + raise_on_error: Raise :class:`CustomerIOSendError` on send failure. Raises: ValueError: If the environment variable is not set. @@ -152,6 +162,7 @@ def from_env( default_from=default_from, max_retries=max_retries, retry_delay=retry_delay, + raise_on_error=raise_on_error, ) @property @@ -256,33 +267,48 @@ def _build_result(response: Any) -> SendResult: @staticmethod def _build_error_result(exc: Exception) -> SendResult: - """Build a ``SendResult`` from an exception.""" - logger.exception("Customer.io API error") - + """Build a ``SendResult`` from an exception. + + Deliberately does not log at warning or above. The library has no + recipient, subject, or template context here, so a log emitted from + this frame is both unactionable and misleading — it makes the failure + look handled. Reporting is the caller's job: the exception is + preserved on the result, and the send methods either raise it or log + it with full context. + """ status_code = 0 if hasattr(exc, "status_code"): status_code = getattr(exc, "status_code", 0) elif hasattr(exc, "code"): status_code = getattr(exc, "code", 0) + logger.debug("Customer.io API error (status=%s)", status_code, exc_info=exc) + return SendResult( ok=False, status_code=status_code, error=str(exc), + exception=exc, ) # ------------------------------------------------------------------ # Retry logic # ------------------------------------------------------------------ - def _send_with_retry(self, send_fn: Any) -> SendResult: + def _send_with_retry(self, send_fn: Any, *, context: str = "") -> SendResult: """Execute a send function, retrying on transient failures with exponential backoff. Args: send_fn: A zero-argument callable that performs the API send. + context: Description of the send, used in retry logs and in the + raised exception message. Returns: A ``SendResult`` with the API response details. + + Raises: + CustomerIOSendError: If the send failed and the client was + constructed with ``raise_on_error=True`` (the default). """ last_result: SendResult | None = None @@ -296,19 +322,26 @@ def _send_with_retry(self, send_fn: Any) -> SendResult: last_result.status_code in _RETRYABLE_STATUS_CODES or last_result.status_code == 0 ): logger.warning( - "Customer.io error (status=%d), retrying (attempt %d/%d)", + "Customer.io error (status=%d) for %s, retrying (attempt %d/%d)", last_result.status_code, + context or "send", attempt + 1, self._max_retries, ) self._backoff(attempt) continue - return last_result + return self._finalize(last_result, context) return self._build_result(response) # All retries exhausted - return last_result # type: ignore[return-value] + return self._finalize(last_result, context) # type: ignore[arg-type] + + def _finalize(self, result: SendResult, context: str = "") -> SendResult: + """Raise if the send failed and ``raise_on_error`` is enabled, else return it.""" + if self._raise_on_error: + result.raise_for_status(context or None) + return result def _backoff(self, attempt: int) -> None: """Sleep with exponential backoff and jitter.""" @@ -438,7 +471,8 @@ def send_email( preheader=preheader, ) - result = self._send_with_retry(lambda r=request: self.client.send_email(r)) + context = f"to={to_str} template={transactional_message_id}" + result = self._send_with_retry(lambda r=request: self.client.send_email(r), context=context) last_result = result if result.ok: logger.info( @@ -447,6 +481,15 @@ def send_email( to_str, result.delivery_id, ) + else: + # Only reachable when raise_on_error=False. + logger.warning( + "Email send failed: template=%s to=%s status=%d error=%s", + transactional_message_id, + to_str, + result.status_code, + result.error, + ) return last_result # type: ignore[return-value] @@ -567,7 +610,8 @@ def _send_inline( preheader=preheader, ) - result = self._send_with_retry(lambda r=request: self.client.send_email(r)) + context = f"to={to_str} subject={subject!r}" + result = self._send_with_retry(lambda r=request: self.client.send_email(r), context=context) last_result = result if result.ok: logger.info( @@ -576,5 +620,14 @@ def _send_inline( to_str, result.delivery_id, ) + else: + # Only reachable when raise_on_error=False. + logger.warning( + "Email send failed: subject=%r to=%s status=%d error=%s", + subject, + to_str, + result.status_code, + result.error, + ) return last_result # type: ignore[return-value] diff --git a/src/altissimo/customerio/exceptions.py b/src/altissimo/customerio/exceptions.py index 4e3f11d..61e9a4a 100644 --- a/src/altissimo/customerio/exceptions.py +++ b/src/altissimo/customerio/exceptions.py @@ -2,11 +2,45 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from altissimo.customerio.models import SendResult + class CustomerIOError(Exception): """Base exception for all altissimo-customerio errors.""" +class CustomerIOSendError(CustomerIOError): + """Raised when a send fails. + + Raised by :meth:`SendResult.raise_for_status` and, when the client is + constructed with ``raise_on_error=True`` (the default), by the send + methods themselves. + + The originating SDK exception is preserved as ``__cause__``, and the + full :class:`SendResult` is available as :attr:`result` for callers + that need the status code or response body. + + Attributes: + status_code: HTTP status code from the Customer.io API (0 if the + request never got a response, e.g. a network error). + result: The ``SendResult`` that failed. + """ + + def __init__( + self, + message: str, + *, + status_code: int = 0, + result: SendResult | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.result = result + + class CustomerIOImportError(CustomerIOError, ImportError): """Raised when the ``customerio`` SDK is not installed. diff --git a/src/altissimo/customerio/models.py b/src/altissimo/customerio/models.py index 22fa811..6070266 100644 --- a/src/altissimo/customerio/models.py +++ b/src/altissimo/customerio/models.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any +from altissimo.customerio.exceptions import CustomerIOSendError + @dataclass(frozen=True, slots=True) class EmailAddress: @@ -35,6 +37,9 @@ class SendResult: status_code: HTTP status code from the Customer.io API (0 on exception). body: Response body as a dictionary. error: Error message if the request failed, ``None`` otherwise. + exception: The originating SDK exception if the request failed, + ``None`` otherwise. Preserved so callers can log or re-raise it + with their own context. """ ok: bool @@ -42,3 +47,27 @@ class SendResult: status_code: int = 0 body: dict[str, Any] = field(default_factory=dict) error: str | None = None + exception: Exception | None = None + + def raise_for_status(self, context: str | None = None) -> None: + """Raise :class:`CustomerIOSendError` if the send failed. + + Mirrors ``requests.Response.raise_for_status`` — a no-op on success, + so it is safe to call unconditionally:: + + client.send_html(to=..., subject=..., html=...).raise_for_status() + + Args: + context: Optional description of what was being sent (e.g. + ``"to=user@example.com subject='Reset your password'"``), + included in the exception message. + + Raises: + CustomerIOSendError: If ``ok`` is ``False``. + """ + if self.ok: + return + + detail = f" for {context}" if context else "" + msg = f"Customer.io send failed (status={self.status_code}){detail}: {self.error}" + raise CustomerIOSendError(msg, status_code=self.status_code, result=self) from self.exception diff --git a/tests/conftest.py b/tests/conftest.py index 2f97519..6d0ee84 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,9 +18,24 @@ def fake_api_client() -> MagicMock: @pytest.fixture def client_with_mock(fake_api_client: MagicMock) -> Any: - """Return a ``CustomerIOClient`` wired to the fake API client.""" + """Return a ``CustomerIOClient`` wired to the fake API client (default: raises on error).""" from altissimo.customerio import CustomerIOClient cio = CustomerIOClient(app_api_key="fake-api-key", region="us", default_from="sender@example.com") cio._client = fake_api_client return cio + + +@pytest.fixture +def client_no_raise(fake_api_client: MagicMock) -> Any: + """Return a ``CustomerIOClient`` in swallow mode, for exercising ``ok=False`` results.""" + from altissimo.customerio import CustomerIOClient + + cio = CustomerIOClient( + app_api_key="fake-api-key", + region="us", + default_from="sender@example.com", + raise_on_error=False, + ) + cio._client = fake_api_client + return cio diff --git a/tests/test_client.py b/tests/test_client.py index 047623e..c9b4365 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,6 +9,7 @@ import pytest from altissimo.customerio.client import CustomerIOClient +from altissimo.customerio.exceptions import CustomerIOSendError from altissimo.customerio.models import EmailAddress # ------------------------------------------------------------------ @@ -228,9 +229,25 @@ def test_send_email_with_bcc(self, client_with_mock: Any, fake_api_client: Magic ) assert result.ok is True - def test_send_email_api_error(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: + def test_send_email_api_error_raises_by_default(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: fake_api_client.send_email.side_effect = Exception("API error") - result = client_with_mock.send_email( + with pytest.raises(CustomerIOSendError) as exc_info: + client_with_mock.send_email( + to="recipient@example.com", + transactional_message_id="tmpl-1", + ) + # The exception message carries the context the library used to swallow. + assert "recipient@example.com" in str(exc_info.value) + assert "tmpl-1" in str(exc_info.value) + assert "API error" in str(exc_info.value) + assert exc_info.value.result is not None + assert exc_info.value.result.ok is False + + def test_send_email_api_error_swallowed_when_opted_out( + self, client_no_raise: Any, fake_api_client: MagicMock + ) -> None: + fake_api_client.send_email.side_effect = Exception("API error") + result = client_no_raise.send_email( to="recipient@example.com", transactional_message_id="tmpl-1", ) @@ -268,9 +285,20 @@ def test_send_text_with_reply_to(self, client_with_mock: Any, fake_api_client: M ) assert result.ok is True - def test_send_text_api_error(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: + def test_send_text_api_error_raises_by_default(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: fake_api_client.send_email.side_effect = Exception("API error") - result = client_with_mock.send_text( + with pytest.raises(CustomerIOSendError): + client_with_mock.send_text( + to="recipient@example.com", + subject="Hello", + body="Text content", + ) + + def test_send_text_api_error_swallowed_when_opted_out( + self, client_no_raise: Any, fake_api_client: MagicMock + ) -> None: + fake_api_client.send_email.side_effect = Exception("API error") + result = client_no_raise.send_text( to="recipient@example.com", subject="Hello", body="Text content", @@ -303,9 +331,20 @@ def test_send_html_with_reply_to(self, client_with_mock: Any, fake_api_client: M ) assert result.ok is True - def test_send_html_api_error(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: + def test_send_html_api_error_raises_by_default(self, client_with_mock: Any, fake_api_client: MagicMock) -> None: fake_api_client.send_email.side_effect = Exception("API error") - result = client_with_mock.send_html( + with pytest.raises(CustomerIOSendError): + client_with_mock.send_html( + to="recipient@example.com", + subject="Hello", + html="Hi
", + ) + mock_logger.warning.assert_called_once() + rendered = mock_logger.warning.call_args[0][0] % mock_logger.warning.call_args[0][1:] + + assert "recipient@example.com" in rendered + assert "Reset your password" in rendered + assert "400" in rendered diff --git a/tests/test_models.py b/tests/test_models.py index 22a8b7b..ad4316e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,11 @@ from __future__ import annotations +import re + +import pytest + +from altissimo.customerio.exceptions import CustomerIOSendError from altissimo.customerio.models import EmailAddress, SendResult @@ -57,10 +62,9 @@ def test_defaults(self) -> None: assert result.status_code == 0 assert result.body == {} assert result.error is None + assert result.exception is None def test_frozen(self) -> None: - import pytest - result = SendResult(ok=True) with pytest.raises(AttributeError): result.ok = False # type: ignore[misc] @@ -69,3 +73,30 @@ def test_equality(self) -> None: a = SendResult(ok=True, delivery_id="abc") b = SendResult(ok=True, delivery_id="abc") assert a == b + + +class TestRaiseForStatus: + """Tests for SendResult.raise_for_status.""" + + def test_noop_on_success(self) -> None: + assert SendResult(ok=True, delivery_id="abc").raise_for_status() is None + + def test_raises_on_failure(self) -> None: + result = SendResult(ok=False, status_code=400, error="Bad request") + with pytest.raises(CustomerIOSendError) as exc_info: + result.raise_for_status() + assert exc_info.value.status_code == 400 + assert exc_info.value.result is result + assert "Bad request" in str(exc_info.value) + + def test_includes_context(self) -> None: + result = SendResult(ok=False, status_code=400, error="Bad request") + with pytest.raises(CustomerIOSendError, match=re.escape("to=user@example.com")): + result.raise_for_status("to=user@example.com") + + def test_chains_original_exception(self) -> None: + original = ValueError("network down") + result = SendResult(ok=False, error="network down", exception=original) + with pytest.raises(CustomerIOSendError) as exc_info: + result.raise_for_status() + assert exc_info.value.__cause__ is original