Skip to content

fix(self-hosted): 502 la trimiteri externe deși mesajul pleacă — semantică corectă de eșec + idempotență la retry#53

Open
andrei-hasna wants to merge 2 commits into
mainfrom
fix/send-failure-semantics-502
Open

fix(self-hosted): 502 la trimiteri externe deși mesajul pleacă — semantică corectă de eșec + idempotență la retry#53
andrei-hasna wants to merge 2 commits into
mainfrom
fix/send-failure-semantics-502

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Incident (2026-07-25, producție, serviciul self-hosted 1.2.7)

emails send către orice destinatar EXTERN returna HTTP 502 deși mesajul pleca efectiv (apărea în emails log ca trimis). Operatorul, văzând 502, a reîncercat → 3 copii ale aceluiași email real către un client extern + 2 emailuri de diagnostic trimise din greșeală. Către adrese proprii (@hasna.com) răspunsul era 200 corect. În plus, emails log --json returna to/cc ca null.

Cauză

  • În src/server/self-hosted/service.ts, eroarea aruncată de provider la trimitere era înghițită de un catch {} gol, rândul din ledger era marcat uncertain, iar clientul primea un 502 generic — indiferent dacă providerul refuzase cererea (nimic trimis) sau dacă trimiterea reușise și doar finalizarea ledger-ului eșuase (trimis, dar raportat ca eșec). Exact această ambiguitate a produs reîncercările și duplicarea.
  • Semnalul central al incidentului — mesaj LIVRAT + 502 la client — corespunde căii „provider OK + eșec la finalizarea post-trimitere”, care înainte răspundea tot 502.
  • to/cc null în emails log --json: câmpurile nu erau serializate pe calea JSON a CLI-ului (afișarea tabelară le citea din altă parte).

Fix (2 commit-uri: a4ae6d9, 88c8381)

Invariant central: o trimitere reușită nu mai arată NICIODATĂ ca eșec.

Server (/v1/messages/send):

  • Clasificare a erorii providerului (classifyProviderSendError, sender.ts), logată întotdeauna:
    • reject definitiv 4xx422 cu eroarea reală, reason: provider_rejected, sent: false, retry_safe: true; rând ledger în noua stare terminală send_state='failed' (fără reconciliere, exclus din cotă, anulabil);
    • eșec indeterminat (rețea/5xx) → rămâne 502, dar numește eroarea, sent: null, „poate sau nu să fi fost trimis”.
  • Provider OK + eșec la finalizarea ledger-ului → 202 cu sent: true, provider_message_id și warning explicit „do NOT retry” (vechiul 502 de aici este ce a triplat emailul real).
  • sent: true + provider_message_id la nivelul de sus pe TOATE căile în care providerul a acceptat mesajul (succes proaspăt 202, replay idempotent 200, finalizare eșuată 202) — clientul nu mai deduce din statusul HTTP.
  • Retry cu aceeași idempotency_key după reject definitiv re-armează ACELAȘI rând (rearmFailedSendIntent) — zero duplicate; dacă re-armarea eșuează (pană DB) → 503 cu sent: false, reason: rearm_failed, retry_safe: true, „NOTHING was sent” (providerul nu e invocat pe această cale — dovedit în test), în loc de 500 generic.
  • OpenAPI actualizat: 422, 503, câmpurile sent/provider_message_id/warning, starea failed.

CLI:

  • emails log --json serializează corect to, cc, cc_addresses, status, send_state; tabelul are coloană Status evidențiată.
  • emails send afișează provider_message_id real (înainte afișa id-ul de rând din ledger) și relansează detaliul de eroare + warning-ul „sent-but-finalization-failed” de la server.
  • emails show afișează linia Status, evidențiată când mesajul nu e livrat.

Teste

  • send-failure-semantics.integration.test.ts (Postgres real, pipeline complet de request) fixează toate cele 5 criterii ale incidentului; unit: clasificatorul de erori, serializarea CLI, contractul OpenAPI.
  • Verificare adversarială (2 iterații independente, Postgres efemer separat): suită completă 2147 pass / 2 skip / 1 fail — unicul fail (multi-tenancy.integration.test.ts › tenant-scoped key issuance) reprodus identic pe origin/main (1ac2d154) nemodificat, deci pre-existent și fără legătură. Revert-proof: cu sursele revertate și testele păstrate, 13 teste + 2 erori pică pe bază, 6 pică pe iterația 1 — testele fixează real comportamentul.
  • Typecheck și build curate (Bun). Nicio trimitere externă reală în teste (sender simulat + domenii .example).

Blast radius

  • Doar serverul self-hosted + CLI-ul @hasna/emails. Contract API extins compatibil (câmpuri noi + statusuri 422/503 documentate); clienții existenți care tratau orice non-2xx ca eșec primesc acum semantică CORECTĂ (422/503 = sigur netrimis, 202+warning = trimis).
  • Schemă: noua stare send_state='failed' în ledger.

Rollback

  • Revert la 1ac2d154 (cele 2 commit-uri sunt izolate pe acest branch). Starea failed din ledger e aditivă; rândurile marcate failed ar reveni la semantica veche uncertain, fără pierdere de date.

⚠️ Stare verificare: NU a trecut — NU publicați / NU deployați încă

Verificarea adversarială a confirmat implementarea, testele și absența regresiilor, dar verdictul final NU este pozitiv. Ce blochează:

  1. Cauza-rădăcină a incidentului de producție nu e confirmată pe viu. Iterația 1 a atribuit incidentul unui reject SES (sandbox MessageRejected) — contrazis de dovezi: la un reject nimic nu pleacă, dar în incident mesajele AU FOST livrate. Calea reparată „provider OK + finalizare ledger eșuată” explică simptomul, dar nu s-a demonstrat CE anume eșuează în finalizare doar pentru destinatari externi pe instanța de producție (ipoteze neînchise: hook post-trimitere pe contact extern, suppression, timeout ALB).
  2. Criteriile live 1, 2 și 5 nu pot fi probate decât după deploy, iar deploy-ul e interzis până trece verificarea — criteriul 1 (extern → 2xx + Message ID) trebuie testat după deploy pe un domeniu de test controlat de noi, NU pe adresa reală a unui terț.

Pași rămași înainte de merge/publish: diagnostic pe viu al căii externe (logurile noi introduse de acest PR vor arăta exact eroarea de finalizare, care înainte era înghițită), re-verificare adversarială cu verdict pozitiv, apoi publish (cu anunț în git-publishing) + redeploy + verificarea live a celor 5 criterii.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…them as generic 502

Incident 2026-07-25: SES sandbox MessageRejected (any external recipient) was
swallowed by a bare catch, marked 'uncertain', and answered with a generic 502
'send outcome is uncertain'. Operators retried and duplicated real client mail;
the CLI log rendered the never-sent rows identically to delivered ones.

Server (/v1/messages/send):
- classify the thrown provider error (classifyProviderSendError): a received
  4xx means the provider REFUSED the request, so nothing was sent
- definitive reject -> 422 + real provider error + reason=provider_rejected +
  sent:false + retry_safe:true; ledger row -> new send_state 'failed'
  (terminal, no reconciliation, excluded from quota usage, cancellable)
- retrying the same idempotency_key after a definitive reject re-arms the SAME
  ledger row (rearmFailedSendIntent) — never a duplicate message
- provider success + ledger finalization failure -> 202 with sent:true,
  provider_message_id, and an explicit do-NOT-retry warning: a successful send
  must never present as an error (that is what caused the triple-send)
- indeterminate failure (network/5xx) stays 502 but names the provider error,
  sets sent:null, and states the outcome is unknown; every provider send error
  is now logged instead of discarded
- OpenAPI: 422 response, sent/provider_message_id/warning fields, failed state

CLI:
- emails log --json serializes to, cc, cc_addresses, status, send_state
  (to/cc read as null during the incident); table gains a highlighted Status
  column so uncertain/failed can never render as delivered
- emails send relays the server's error detail and prints the
  sent-but-finalization-failed warning

Tests: send-failure-semantics.integration.test.ts (real Postgres, full request
pipeline) locks all five incident criteria; sender.test.ts covers the error
classifier; email-log.remote.test.ts + data-source tests cover the CLI
serialization; the pre-existing inbound finalization-failure test updated to
the corrected contract.
…r-accepted response; CLI shows the real provider id and per-message status

Iteration 2 on the 2026-07-25 incident branch. The core invariant — a
successful send must NEVER present as a failure — is now checkable in ONE
place on every response where the provider accepted the message:

Server (/v1/messages/send):
- fresh success (202), idempotent replay of a sent intent (200), and the
  post-send finalization-failure path (202 + warning) all carry top-level
  sent: true and provider_message_id
- a rejected intent whose retry cannot be re-armed answers 503 with
  sent: false, reason: rearm_failed, retry_safe: true (and logs the error)
  instead of falling through to a generic 500 that hides the fact that
  nothing was sent — the exact ambiguity that caused the incident retries
- OpenAPI: 503 response documented; sent/provider_message_id descriptions
  updated to cover all provider-accepted paths

CLI:
- emails send resolves Message ID as top-level provider_message_id, then
  the record's provider_message_id, then message_id, then the ledger id —
  it printed the ledger row id for self-hosted sends before
- emails show (single-message view) now prints the ledger Status line,
  highlighted when not delivered, so an uncertain/failed row can no longer
  read as delivered anywhere in the CLI (list already showed it)

Tests (fail on the previous commit, pass here): integration — top-level
sent/provider_message_id on fresh success and replay, 503 rearm-outage
contract with same-row retry afterwards; unit — provider-id preference in
the data source, Status line in the detail view, 503 in the OpenAPI
contract. Full suite: 2147 pass / 2 skip / 1 fail — the single failure
(multi-tenancy key issuance) reproduced unchanged on the origin/main base
commit 1ac2d15 in a pristine worktree (pre-existing, unrelated).
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Not merging this PR. Two independent adversarial reviews reached the same conclusion, and I verified it before deciding.

It is a strict subset of #55. #55 carries both of this PR's commits, from the same merge base (1ac2d15), and comm -23 of the two file lists is empty. Merging both is not additive — with #55 in first, this PR produces real conflicts in src/server/self-hosted/sender.ts, src/server/self-hosted/sender.test.ts and src/lib/self-hosted-mail-data-source.test.ts.

On its own it is also red: it leaves the verify job failing (the SDK generator anchored its nullable-message patches on whole interface literals, so adding a sibling field broke generation). #55 fixes that in scripts/generate-selfhost-sdk.ts.

Everything this PR set out to do — the 4xx/5xx split, the honest 502, idempotent retry on the same ledger row — ships in #55, plus the per-provider credential fix and the reconciliation path for the messages already stranded as uncertain.

Closing as superseded by #55. No work is lost; nothing here is unmerged behaviour.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Reopened immediately after closing: the delivery instruction for this batch is to leave a must-not-merge PR open with the reason recorded, not to close it. The reason stands exactly as written in the previous comment — superseded by #55, do not merge. Left open for the owner to close or convert.

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