fix: surface send failures instead of swallowing them - #1
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A failed send returned
SendResult(ok=False)and was otherwise indistinguishable from success. Python has nomust_use, so nothing prompted callers to checkresult.ok— and the library compounded it by callinglogger.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 Exceptionand 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 returned202 Acceptedwith no email sent and nothing actionable in the logs.Changes
CustomerIOSendErrorby default. Passraise_on_error=Falseto the constructor orfrom_env()for batch sends where one bad recipient shouldn't abort the run — that mode now logs awarningwith full context instead of failing mute.SendResult.raise_for_status(context?)— mirrorsrequests.Response.raise_for_status, for callers that want to opt in per call site rather than per client.SendResult.exceptionpreserves the originating SDK exception, andCustomerIOSendErrorchains it as__cause__, so callers can log or re-raise with their own context.logger.exception()from_build_error_resultin favour oflogger.debug(exc_info=...). Reporting belongs to the caller; the library's job is to hand back enough to report with.Why raise by default
SendResultis the right return type —delivery_idis the only handle for tracing a delivery in Customer.io,status_codedistinguishes config errors from transient ones, and multi-recipient sends have real partial-failure semantics. The problem was never the return type; it was thatok=Falseis 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_errordefaults toTrue. Callers relying onok=Falsemust either passraise_on_error=Falseor handleCustomerIOSendError. 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 atry/exceptaround 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.pyandmodels.pyat 100%)ruff check/ruff format --check: cleanpre-commit run --all-files: all hooks pass_build_error_resultlogs nothing aboveDEBUG— the regression this PR is aboutmypy srcreports 2 pre-existing errors from the untypedcustomerioSDK (base commit reports 6); none added hereAlso added
MD024: siblings_onlyto.markdownlint.json— Keep-a-Changelog repeats### Addedper release, which only trips the linter now that there's a second release.