fix(destregistry): record the cause of a transport-level delivery failure - #1018
Open
alexluong wants to merge 1 commit into
Open
fix(destregistry): record the cause of a transport-level delivery failure#1018alexluong wants to merge 1 commit into
alexluong wants to merge 1 commit into
Conversation
…lure Of the four failure paths through destwebhook, the transport branch was the only one that left Delivery.Response nil. A connection-level failure — DNS, refused, TLS, dial timeout — therefore stored response_data NULL, and the reason it failed was not recoverable afterwards: the one copy of the error string went onto the publish-attempt error, which the delivery consumer deliberately suppresses. Record the cause on the attempt. Which string is safe depends on the branch: a plain transport error is a *url.Error naming the customer's own destination and nothing of ours, while ErrProxyDestination.Error() appends proxy diagnostics that are operator-side only — so that branch records the classification and destination host alone, matching the rule the adjacent diagnostics block already follows. Also classify EADDRNOTAVAIL as address_unavailable instead of letting source-side ephemeral port exhaustion fall into the network_error catch-all beside genuinely remote failures; the fix for it is connection reuse, not a retry against the destination. Matched on the errno rather than the message, which is not portable — Linux renders it "cannot assign requested address" and darwin "can't". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D7FsF8MDpWkeZFJdLYahTX
Collaborator
Author
|
@alexbouchardd @leggetter would appreciate your input here. Please see the PR description for context. I'm not 100% sold on this PR itself but I think it highlights a potential problem in Outpost behavior when it comes to surfacing error to end users. |
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.
Problem
When a webhook delivery fails before receiving an HTTP response — DNS failure, connection refused, TLS error, dial timeout — the attempt records that it failed and nothing about why.
response_datais written NULL.To be precise about which failures these are: a destination that replies
503is not this path — that's an HTTP response, it goes down the 4xx/5xx branch, and its body is already recorded. This path is the case where no response ever arrives.It's the only delivery failure path through
destwebhookthat behaves this way:coderesponse_data"503""ERR""ERR"connection_refusedA destination that returns a 500 with an empty body leaves more diagnostic trace than a destination we could not reach at all.
What the end user sees
GET /destinations/:id/attempts?include=response_data, for a delivery to a host that isn't accepting connections.Before:
{ "id": "att_...", "status": "failed", "code": "connection_refused", "attempt_number": 1 }response_datais omitted entirely — it was NULL, and the field isomitempty. The user knows the category and nothing else: which host, which port, whether it was one endpoint or all of them.After:
{ "id": "att_...", "status": "failed", "code": "connection_refused", "response_data": { "error": "Post \"https://api.example.com/hooks/abc\": dial tcp 203.0.113.10:443: connect: connection refused" }, "attempt_number": 1 }The
codeis unchanged. What's new is theresponse_data.errorstring, which names the URL, the resolved address, and the failing syscall — enough to tell "my DNS points at a stale IP" from "my service is down" from "my TLS cert expired" without contacting support.Worth noting the string is Go's raw transport error, so it reads like a Go error rather than a written message. It's the same trade the 4xx/5xx path already makes by storing the destination's response body verbatim. Normalizing it is a reasonable follow-up; recording it at all is the change here.
The catch-all case is where this matters most.
dns_errorandconnection_refusedat least name a category;network_erroris everything unrecognized, and before this change an attempt with that code carried no information whatsoever.Both webhook providers get this —
destwebhookanddestwebhookstandardshare the same helper.desthookdeckalready records the cause on its own error path and is unaffected.Open question: is
response_datathe right field for this?There's a reasonable objection to the change above: the connection failed, so there is no response — leaving
response_dataempty is arguably correct, and putting an error string there is a misuse of the field.Raising it because the answer isn't obvious, and because the field already has this problem independently of this PR.
response_datais not "the HTTP response" today.NewFormatErrorfires when an event can't be turned into a request at all — an unparseable payload, an invalid partition-key or object-key template. Nothing is sent, no connection is opened, and it still fills the field:Eight providers call it —
destwebhook,destwebhookstandard,destkafka,destawss3,destawssqs,destawskinesis,destgcppubsub,destazureservicebus. Every one passes an empty message, so what the customer actually receives is the literal default:{ "id": "att_...", "status": "failed", "code": "ERR", "response_data": { "error": "could not format event for delivery" }, "attempt_number": 1 }A
response_datafor an attempt where no request ever left the process. Validation and publisher-resolution failures do the same, storing{"error": err.Error()}underCode: "ERR".So the field carries three distinct shapes:
{"status": 503, "body": "..."}{"error": "could not format event for delivery"}{"error": "<err.Error()>"}And the doc comment on
NewFormatErrorstates the intent outright: "message is the customer-facing string persisted on the attempt (ResponseData)." Not "the response" — the customer-facing string.That leaves two positions:
response_datais the attempt's explanation field, and the name is historical. This PR follows that, making the transport path consistent with the two other no-response failures that already populate it.response_datashould strictly mean a response, and the explanation belongs in a separateerror/failure_reasonfield. Cleaner, but it touchesmodels.Attempt, both log store schemas, the API type, and the SDKs — plus the{"error": ...}values already sitting in customers' attempt history.This PR takes the first, since it's what the surrounding code does. If we'd rather have the second, this becomes one more call site to migrate rather than an obstacle — but worth deciding, because the inconsistency is customer-visible either way.
Also: source-side failures are no longer charged to the destination
Ephemeral port exhaustion on the sending host previously fell into the
network_errorcatch-all, beside genuinely remote failures. It now recordsaddress_unavailable.This matters to the user because the two need different responses: a destination that's unreachable is theirs to fix, while running out of local ports is ours, and retrying against the destination won't help. Under the old classification the two were indistinguishable on the attempt.
It does change a customer-visible value — an attempt that would have recorded
network_errornow recordsaddress_unavailable, so anything matching on that string sees new behavior. Happy to split this out if you'd rather it land separately.Testing
go test -short ./...passes. Adds coverage asserting a connection-level failure records a non-empty cause, plus the errno classification.🤖 Generated with Claude Code