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
1 change: 1 addition & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"MD013": false,
"MD024": { "siblings_only": true },
"MD033": false,
"MD041": false
}
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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="<p>Hi</p>")
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
Expand All @@ -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)
)
```

Expand All @@ -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) |
Expand All @@ -104,14 +147,27 @@ 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

```text
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
```
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 3 additions & 2 deletions src/altissimo/customerio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 62 additions & 9 deletions src/altissimo/customerio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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]

Expand Down Expand Up @@ -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(
Expand All @@ -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]
Loading
Loading