fix(self-hosted): 502 la trimiteri externe deși mesajul pleacă — semantică corectă de eșec + idempotență la retry#53
Conversation
…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).
|
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 ( On its own it is also red: it leaves the 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 Closing as superseded by #55. No work is lost; nothing here is unmerged behaviour. |
|
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. |
Incident (2026-07-25, producție, serviciul self-hosted 1.2.7)
emails sendcătre orice destinatar EXTERN returna HTTP 502 deși mesajul pleca efectiv (apărea înemails logca 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 --jsonreturnato/cccanull.Cauză
src/server/self-hosted/service.ts, eroarea aruncată de provider la trimitere era înghițită de uncatch {}gol, rândul din ledger era marcatuncertain, 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.to/ccnull înemails 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):classifyProviderSendError,sender.ts), logată întotdeauna:422cu 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);502, dar numește eroarea,sent: null, „poate sau nu să fi fost trimis”.202cusent: true,provider_message_idși warning explicit „do NOT retry” (vechiul 502 de aici este ce a triplat emailul real).sent: true+provider_message_idla 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.idempotency_keydupă reject definitiv re-armează ACELAȘI rând (rearmFailedSendIntent) — zero duplicate; dacă re-armarea eșuează (pană DB) →503cusent: 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.sent/provider_message_id/warning, stareafailed.CLI:
emails log --jsonserializează corectto,cc,cc_addresses,status,send_state; tabelul are coloană Status evidențiată.emails sendafișeazăprovider_message_idreal (î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 showafiș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.multi-tenancy.integration.test.ts › tenant-scoped key issuance) reprodus identic peorigin/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..example).Blast radius
@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).send_state='failed'în ledger.Rollback
1ac2d154(cele 2 commit-uri sunt izolate pe acest branch). Stareafaileddin ledger e aditivă; rândurile marcatefailedar reveni la semantica vecheuncertain, fără pierdere de date.Verificarea adversarială a confirmat implementarea, testele și absența regresiilor, dar verdictul final NU este pozitiv. Ce blochează:
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).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.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.