You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Correction (see comment below): the original version of this issue claimed a non-2xx response produces no log output at all. That was wrong — the SendGrid SDK raises on non-2xx, so failures do reach _build_error_result and do log. The body below has been corrected. The core problem stands, and two related defects turned out to be more concrete than originally described.
Context
Found while reviewing altissimo-hq/customerio-python#1. customerio-python was derived from this package, so it inherited the same error-reporting design. This issue tracks the equivalent fix here.
1. Send failures are silently ignorable (the shared problem)
A failed send returns SendResult(ok=False) and is otherwise indistinguishable from success. Python has no must_use, so nothing prompts a caller to check result.ok; a caller that forgets gets silence. The library compounds it in _build_error_result (client.py:259-266):
def_build_error_result(self, exc: Exception) ->SendResult:
logger.exception("SendGrid API error")
returnSendResult(ok=False, status_code=0, error=str(exc))
That log fires from a frame with no recipient, subject, or template context, so it is unactionable — while simultaneously making the failure look handled. The exception object is discarded; only str(exc) survives, so a caller cannot inspect or re-raise it.
2. status_code is always 0 for real API failures
_build_error_result hardcodes status_code=0. But the SendGrid SDK raises on non-2xx — python_http_client.client.Client._make_request ends in raise exc — and every python_http_client.exceptions.HTTPError carries self.status_code, set in its __init__, for all of 400, 401, 403, 404, 405, 413, 415, 429, 500, 503, 504.
So the actual HTTP status is sitting right there on the exception and gets thrown away. Every real API failure — bad key, unverified sender, malformed payload, rate limit — comes back as status_code=0, and callers cannot tell a permanent 400 from a transient 429.
customerio-python does not have this bug; its version reads exc.status_code when present. The copy-paste diverged in the wrong direction here.
3. Retry classification is dead code
Because the SDK raises on non-2xx, real failures only ever arrive via the except Exception branch of _send_with_retry (client.py:308-313), which retries unconditionally:
exceptExceptionasexc:
last_result=self._build_error_result(exc)
# Exceptions (network errors, etc.) are also retryableifattempt<self._max_retries:
self._backoff(attempt)
continue
_RETRYABLE_STATUS_CODES (429, 500, 502, 503, 504) is consulted only at client.py:318, on the _build_result path — which in practice only ever sees 2xx responses, since anything else raised. Two consequences:
The intended "retry only 429 and 5xx" policy never takes effect.
With max_retries > 0, a permanent 400 or 403 burns the full backoff schedule before returning. Inert at the default max_retries=0, but that is the only thing currently saving it.
Fixing #2 is a prerequisite for fixing this: you cannot classify what you have discarded.
4. The 4xx tests exercise an unreachable path
tests/test_client.py:193-196 and 385-389 assert ok is False for FakeResponse(status_code=400) — a response returned with a 400. The real SDK raises instead, so _build_result's ok=200 <= status_code < 300 branch is effectively dead, and these tests pass while the production path they appear to cover behaves differently. Worth keeping the fixture for defence-in-depth, but the raising path needs its own coverage.
Proposed fix
Mirror what landed in customerio-python#1, so the two packages stay ergonomically identical, plus the two defects specific to this one:
SendGridSendError in exceptions.py, subclassing SendGridError, carrying status_code and the failed result, chaining the originating exception as __cause__.
SendResult.raise_for_status(context?) — mirrors requests.Response.raise_for_status; no-op on success.
raise_on_error: bool on the constructor and from_env(). Applied at the single point where a result is finalized, so it covers both the raising and the returned-response paths.
SendResult.exception field preserving the originating exception.
Read exc.status_code in _build_error_result instead of hardcoding 0 (fixes feat: Support multiple recipients, CC, and BCC #2), and replace logger.exception with logger.debug(..., exc_info=exc), logging a warningwith context from the send methods in non-raising mode.
In customerio-python this defaults to True, on the reasoning that the failure modes aren't symmetric: default-swallow fails invisibly, default-raise fails loudly and forces an acknowledgement.
That call is less obvious here, because this package has live consumers. Items 1, 2, 4, 5, and 6 are all non-breaking and fix the substantive defects on their own; only the default flip is breaking. Reasonable sequencing is to ship those first, then flip the default in a follow-up once consumers are audited and pinned forward.
Consumers
everygene does not currently use this package, though there is a plan to migrate it here. It has its own in-repo client at everygene/package/src/everygene/sendgrid/client.py, and it is a well-behaved consumer worth copying from:
send_mail catches HTTPError, logs at error level with the message, and returns {"status": "error", "message": ...}.
Every call site validates explicitly via _ensure_sendgrid_ok (everygene/users/router.py:34-38), converting a failure into an HTTP 502.
So the migration needs a deliberate contract mapping — {"status": "ok"} → result.ok, and _ensure_sendgrid_ok → either an ok check or a try/except SendGridSendError. Both work; the point is that it should be chosen rather than inherited. Note that everygene sets "status": "ok" on "no exception raised", which is sound precisely because the SDK raises on non-2xx.
darwinsark also consumes this package. I could not verify its pin or whether any call site depends on ok=False; someone with visibility should confirm before the default is flipped.
Note
The README quick start does show assert result.ok (README.md:59), so the docs gesture at checking — but an assert in a quick start is not a substitute for an API that cannot be ignored.
Context
Found while reviewing altissimo-hq/customerio-python#1.
customerio-pythonwas derived from this package, so it inherited the same error-reporting design. This issue tracks the equivalent fix here.1. Send failures are silently ignorable (the shared problem)
A failed send returns
SendResult(ok=False)and is otherwise indistinguishable from success. Python has nomust_use, so nothing prompts a caller to checkresult.ok; a caller that forgets gets silence. The library compounds it in_build_error_result(client.py:259-266):That log fires from a frame with no recipient, subject, or template context, so it is unactionable — while simultaneously making the failure look handled. The exception object is discarded; only
str(exc)survives, so a caller cannot inspect or re-raise it.2.
status_codeis always0for real API failures_build_error_resulthardcodesstatus_code=0. But the SendGrid SDK raises on non-2xx —python_http_client.client.Client._make_requestends inraise exc— and everypython_http_client.exceptions.HTTPErrorcarriesself.status_code, set in its__init__, for all of400, 401, 403, 404, 405, 413, 415, 429, 500, 503, 504.So the actual HTTP status is sitting right there on the exception and gets thrown away. Every real API failure — bad key, unverified sender, malformed payload, rate limit — comes back as
status_code=0, and callers cannot tell a permanent 400 from a transient 429.customerio-pythondoes not have this bug; its version readsexc.status_codewhen present. The copy-paste diverged in the wrong direction here.3. Retry classification is dead code
Because the SDK raises on non-2xx, real failures only ever arrive via the
except Exceptionbranch of_send_with_retry(client.py:308-313), which retries unconditionally:_RETRYABLE_STATUS_CODES(429, 500, 502, 503, 504) is consulted only atclient.py:318, on the_build_resultpath — which in practice only ever sees 2xx responses, since anything else raised. Two consequences:max_retries > 0, a permanent400or403burns the full backoff schedule before returning. Inert at the defaultmax_retries=0, but that is the only thing currently saving it.Fixing #2 is a prerequisite for fixing this: you cannot classify what you have discarded.
4. The 4xx tests exercise an unreachable path
tests/test_client.py:193-196and385-389assertok is FalseforFakeResponse(status_code=400)— a response returned with a 400. The real SDK raises instead, so_build_result'sok=200 <= status_code < 300branch is effectively dead, and these tests pass while the production path they appear to cover behaves differently. Worth keeping the fixture for defence-in-depth, but the raising path needs its own coverage.Proposed fix
Mirror what landed in customerio-python#1, so the two packages stay ergonomically identical, plus the two defects specific to this one:
SendGridSendErrorinexceptions.py, subclassingSendGridError, carryingstatus_codeand the failedresult, chaining the originating exception as__cause__.SendResult.raise_for_status(context?)— mirrorsrequests.Response.raise_for_status; no-op on success.raise_on_error: boolon the constructor andfrom_env(). Applied at the single point where a result is finalized, so it covers both the raising and the returned-response paths.SendResult.exceptionfield preserving the originating exception.exc.status_codein_build_error_resultinstead of hardcoding0(fixes feat: Support multiple recipients, CC, and BCC #2), and replacelogger.exceptionwithlogger.debug(..., exc_info=exc), logging awarningwith context from the send methods in non-raising mode._RETRYABLE_STATUS_CODESto the exception path now that the status survives, so permanent failures stop retrying (fixes feat: Configurable retry with exponential backoff #3). Keep retryingstatus_code == 0, which after feat: From name support, multiple recipients, CC/BCC, success logging #5 means a genuine network error with no HTTP response.Default value of
raise_on_errorIn
customerio-pythonthis defaults toTrue, on the reasoning that the failure modes aren't symmetric: default-swallow fails invisibly, default-raise fails loudly and forces an acknowledgement.That call is less obvious here, because this package has live consumers. Items 1, 2, 4, 5, and 6 are all non-breaking and fix the substantive defects on their own; only the default flip is breaking. Reasonable sequencing is to ship those first, then flip the default in a follow-up once consumers are audited and pinned forward.
Consumers
everygenedoes not currently use this package, though there is a plan to migrate it here. It has its own in-repo client ateverygene/package/src/everygene/sendgrid/client.py, and it is a well-behaved consumer worth copying from:send_mailcatchesHTTPError, logs aterrorlevel with the message, and returns{"status": "error", "message": ...}._ensure_sendgrid_ok(everygene/users/router.py:34-38), converting a failure into an HTTP 502.So the migration needs a deliberate contract mapping —
{"status": "ok"}→result.ok, and_ensure_sendgrid_ok→ either anokcheck or atry/except SendGridSendError. Both work; the point is that it should be chosen rather than inherited. Note that everygene sets"status": "ok"on "no exception raised", which is sound precisely because the SDK raises on non-2xx.darwinsarkalso consumes this package. I could not verify its pin or whether any call site depends onok=False; someone with visibility should confirm before the default is flipped.Note
The README quick start does show
assert result.ok(README.md:59), so the docs gesture at checking — but an assert in a quick start is not a substitute for an API that cannot be ignored.