Skip to content

fix: surface send failures instead of swallowing them - #1

Merged
lukwam merged 1 commit into
mainfrom
fix/surface-send-failures
Jul 29, 2026
Merged

fix: surface send failures instead of swallowing them#1
lukwam merged 1 commit into
mainfrom
fix/surface-send-failures

Conversation

@lukwam

@lukwam lukwam commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

A failed send returned SendResult(ok=False) and was otherwise indistinguishable from success. Python has no must_use, so nothing prompted callers to check result.ok — and the library compounded it by calling logger.exception() from _build_error_result, a frame with no recipient, subject, or template context. The net effect was a scary but unactionable traceback attributed to this library, plus a caller carrying on as if the email had been sent.

Found while reviewing the first real consumer of this package (livedinc/lived#368, a backend password-reset endpoint). It wrapped the send in try/except Exception and logged on the error path — a handler that could never fire, because the library had already swallowed the failure. An unverified sender identity or a bad API key would have returned 202 Accepted with no email sent and nothing actionable in the logs.

Changes

  • Send failures raise CustomerIOSendError by default. Pass raise_on_error=False to the constructor or from_env() for batch sends where one bad recipient shouldn't abort the run — that mode now logs a warning with full context instead of failing mute.
  • SendResult.raise_for_status(context?) — mirrors requests.Response.raise_for_status, for callers that want to opt in per call site rather than per client.
  • SendResult.exception preserves the originating SDK exception, and CustomerIOSendError chains it as __cause__, so callers can log or re-raise with their own context.
  • Dropped logger.exception() from _build_error_result in favour of logger.debug(exc_info=...). Reporting belongs to the caller; the library's job is to hand back enough to report with.
  • Retry warnings now include the recipient and the template or subject being sent.

Why raise by default

SendResult is the right return type — delivery_id is the only handle for tracing a delivery in Customer.io, status_code distinguishes config errors from transient ones, and multi-recipient sends have real partial-failure semantics. The problem was never the return type; it was that ok=False is silently ignorable.

The two failure modes aren't symmetric. Default-swallow fails invisibly. Default-raise fails loudly and forces an acknowledgement. For a library whose entire job is "did my user get this message", loud wins.

Breaking change

raise_on_error defaults to True. Callers relying on ok=False must either pass raise_on_error=False or handle CustomerIOSendError. Version bumped to 0.2.0.

At time of writing this package has exactly one consumer (livedinc/lived, pinned to 4d4307d), and that consumer already has a try/except around the send that this change makes functional — so the migration there is a SHA bump with no code change. Nothing else needs migrating.

Testing

  • pytest: 89 passed, coverage 89% (exceptions.py and models.py at 100%)
  • ruff check / ruff format --check: clean
  • pre-commit run --all-files: all hooks pass
  • Existing error-path tests kept, retargeted at the opt-out fixture, and paired with new raising-mode assertions so both modes are covered
  • New test asserts _build_error_result logs nothing above DEBUG — the regression this PR is about
  • mypy src reports 2 pre-existing errors from the untyped customerio SDK (base commit reports 6); none added here

Also added MD024: siblings_only to .markdownlint.json — Keep-a-Changelog repeats ### Added per release, which only trips the linter now that there's a second release.

A failed send returned SendResult(ok=False) and was otherwise
indistinguishable from success. Python has no must_use, so nothing
prompted callers to check result.ok — and the library compounded it by
calling logger.exception() from _build_error_result, a frame with no
recipient, subject, or template context. The result was a scary but
unactionable traceback attributed to this library, plus a caller that
carried on as if the email had been sent.

Found while reviewing the first real consumer, which wrapped the send in
try/except and returned success on the error path — a bare except that
could never fire, because the library had already swallowed the failure.

Changes:

- Send failures now raise CustomerIOSendError by default. Pass
  raise_on_error=False for batch sends where one bad recipient should not
  abort the run; that mode now logs a warning with full context rather
  than failing mute.
- Add SendResult.raise_for_status(context?), mirroring
  requests.Response.raise_for_status, for callers that want to opt in
  per call site.
- Preserve the originating SDK exception on SendResult.exception and
  chain it as __cause__, so callers can log it with their own context.
- Drop logger.exception() from _build_error_result in favour of
  logger.debug(exc_info=...). Reporting belongs to the caller; the
  library's job is to hand back enough to report with.
- Include recipient and template/subject in retry warnings.

BREAKING CHANGE: raise_on_error defaults to True. Callers relying on
ok=False must either pass raise_on_error=False or handle
CustomerIOSendError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukwam
lukwam merged commit 85c377c into main Jul 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant