Skip to content

Commit f43a92f

Browse files
alexluongclaude
andauthored
fix(destregistry): treat event format errors as failed deliveries, not DLQ (#957)
* fix(destregistry): treat event format errors as failed deliveries, not DLQ A Format/key-template failure (e.g. an S3 key_template referencing a field absent from the event) returned a nil delivery, which the registry turned into a nil attempt and the deliverymq handler classified as a PreDeliveryError → nack → Pub/Sub DLQ. The failure was never logged, invisible to the customer, and paged us instead of surfacing as an actionable delivery error. Add destregistry.NewFormatErrorDelivery, returning a non-nil failed Delivery plus an ErrDestinationPublishAttempt, so the registry records a failed attempt, acks the message, and retries via the scheduler. The customer-facing response is a generic message; the raw Go error stays on the error for logs/telemetry and is not persisted on the attempt. Apply it across all providers with a Format step: s3, sqs, azure_servicebus, gcp_pubsub, webhook, webhook_standard (previously `return nil, err`) and kinesis, kafka (previously nil-delivery ErrDestinationPublishAttempt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(e2e): regression for format error delivered as failed attempt, not DLQ Standalone e2e test reproducing the production incident: an aws_s3 destination whose key_template references a field missing from the event. Asserts the fixed behavior end to end — nothing is written to S3, each delivery is recorded as a failed attempt carrying the format error, and retries run on the normal schedule and exhaust their budget rather than being nacked/dead-lettered. Verified as a real guard: reverting the destawss3 fix makes this test fail (0 attempts logged, message dead-lettered) instead of recording 3 attempts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(destregistry): rename NewFormatErrorDelivery to NewFormatError The helper returns the (*Delivery, error) pair a publisher returns on a format failure, not just a delivery — name it accordingly. Behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(e2e): trim format-error regression test comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a1d7304 commit f43a92f

12 files changed

Lines changed: 293 additions & 20 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package e2e_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"net/http"
8+
"testing"
9+
"time"
10+
11+
"github.com/aws/aws-sdk-go-v2/aws"
12+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
13+
"github.com/aws/aws-sdk-go-v2/credentials"
14+
"github.com/aws/aws-sdk-go-v2/service/s3"
15+
"github.com/gin-gonic/gin"
16+
"github.com/hookdeck/outpost/cmd/e2e/configs"
17+
"github.com/hookdeck/outpost/internal/app"
18+
"github.com/hookdeck/outpost/internal/config"
19+
"github.com/hookdeck/outpost/internal/util/testinfra"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
// TestE2E_Regression_FormatErrorIsDeliveredAttempt verifies that a per-event
24+
// formatting failure is handled as a normal failed delivery, not a system error
25+
// that gets nacked and dead-lettered.
26+
//
27+
// We use aws_s3 because its key_template is only syntax-validated at creation, so a
28+
// valid template can still fail at delivery for an event missing a referenced field.
29+
//
30+
// Asserts, end to end:
31+
// 1. nothing is written to S3 (the failure is pre-delivery)
32+
// 2. each attempt is recorded as a failed delivery
33+
// 3. it retries on the normal schedule and exhausts, rather than being dead-lettered
34+
func TestE2E_Regression_FormatErrorIsDeliveredAttempt(t *testing.T) {
35+
t.Parallel()
36+
if testing.Short() {
37+
t.Skip("skipping e2e test")
38+
}
39+
40+
testinfraCleanup := testinfra.Start(t)
41+
defer testinfraCleanup()
42+
gin.SetMode(gin.TestMode)
43+
44+
ctx, cancel := context.WithCancel(context.Background())
45+
defer cancel()
46+
47+
// LocalStack S3: the bucket doubles as the "was anything actually delivered?" sink.
48+
endpoint := testinfra.EnsureLocalStack()
49+
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
50+
awsconfig.WithRegion("us-east-1"),
51+
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
52+
)
53+
require.NoError(t, err)
54+
s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
55+
o.UsePathStyle = true // required for LocalStack
56+
o.BaseEndpoint = aws.String(endpoint)
57+
})
58+
bucket := fmt.Sprintf("regr-format-%d", time.Now().UnixNano())
59+
_, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
60+
require.NoError(t, err)
61+
62+
// Isolated outpost instance with a short, bounded retry schedule so the budget
63+
// exhausts quickly: schedule length 2 => 2 retries => 3 total attempts.
64+
cfg := configs.Basic(t, configs.BasicOpts{LogStorage: configs.LogStorageTypeClickHouse})
65+
cfg.RetrySchedule = []int{1, 1}
66+
cfg.RetryPollBackoffMs = 50
67+
cfg.LogBatchThresholdSeconds = 0 // immediate flush so /attempts is reliable
68+
require.NoError(t, cfg.Validate(config.Flags{}))
69+
configs.ApplyMigrations(t, &cfg)
70+
71+
appDone := make(chan struct{})
72+
go func() {
73+
defer close(appDone)
74+
application := app.New(&cfg)
75+
if err := application.Run(ctx); err != nil {
76+
log.Println("Application stopped:", err)
77+
}
78+
}()
79+
defer func() {
80+
cancel()
81+
<-appDone
82+
}()
83+
84+
waitForHealthy(t, cfg.APIPort, 5*time.Second)
85+
86+
client := newRegressionHTTPClient(cfg.APIKey)
87+
apiURL := fmt.Sprintf("http://localhost:%d/api/v1", cfg.APIPort)
88+
89+
tenantID := fmt.Sprintf("tenant_format_%d", time.Now().UnixNano())
90+
destinationID := fmt.Sprintf("dest_format_%d", time.Now().UnixNano())
91+
eventID := fmt.Sprintf("evt_format_%d", time.Now().UnixNano())
92+
93+
// Create tenant.
94+
status := client.doJSON(t, http.MethodPut, apiURL+"/tenants/"+tenantID, nil, nil)
95+
require.Equal(t, 201, status, "failed to create tenant")
96+
97+
// Create an aws_s3 destination whose key_template is valid at creation but cannot be
98+
// evaluated for an event lacking metadata.operationId (join over a nil value fails).
99+
status = client.doJSON(t, http.MethodPost, apiURL+"/tenants/"+tenantID+"/destinations", map[string]any{
100+
"id": destinationID,
101+
"type": "aws_s3",
102+
"topics": "*",
103+
"config": map[string]any{
104+
"bucket": bucket,
105+
"region": "us-east-1",
106+
"endpoint": endpoint,
107+
"storage_class": "STANDARD",
108+
"key_template": `join('/', ['prefix', metadata.operationId])`,
109+
},
110+
"credentials": map[string]any{
111+
"key": "test",
112+
"secret": "test",
113+
},
114+
}, nil)
115+
require.Equal(t, 201, status, "failed to create aws_s3 destination")
116+
117+
// Publish a retry-eligible event WITHOUT metadata.operationId -> formatting fails at delivery.
118+
status = client.doJSON(t, http.MethodPost, apiURL+"/publish", map[string]any{
119+
"id": eventID,
120+
"tenant_id": tenantID,
121+
"topic": "user.created",
122+
"eligible_for_retry": true,
123+
"metadata": map[string]any{"foo": "bar"},
124+
"data": map[string]any{"hello": "world"},
125+
}, nil)
126+
require.Equal(t, 202, status, "failed to publish event")
127+
128+
attemptsURL := apiURL + "/attempts?tenant_id=" + tenantID + "&event_id=" + eventID + "&dir=asc&include=response_data"
129+
pollAttempts := func(t *testing.T, minCount int, timeout time.Duration) []map[string]any {
130+
t.Helper()
131+
deadline := time.Now().Add(timeout)
132+
for time.Now().Before(deadline) {
133+
var resp struct {
134+
Models []map[string]any `json:"models"`
135+
}
136+
s := client.doJSON(t, http.MethodGet, attemptsURL, nil, &resp)
137+
if s == http.StatusOK && len(resp.Models) >= minCount {
138+
return resp.Models
139+
}
140+
time.Sleep(100 * time.Millisecond)
141+
}
142+
t.Fatalf("timed out waiting for %d attempts", minCount)
143+
return nil
144+
}
145+
146+
// (2) + (3): retries run on the normal schedule and produce recorded failed attempts.
147+
// 1 initial + 2 scheduled retries = 3 attempts. If the fix regressed, the format error
148+
// would nack/dead-letter the message and ZERO attempts would be logged -> this times out.
149+
attempts := pollAttempts(t, 3, 15*time.Second)
150+
for i, atm := range attempts {
151+
require.Equal(t, "failed", atm["status"], "attempt %d should be a failed delivery", i+1)
152+
// (2) the attempt carries the format error as a normal, customer-facing delivery error.
153+
if rd, ok := atm["response_data"].(map[string]any); ok {
154+
require.Equal(t, "could not format event for delivery", rd["error"],
155+
"attempt %d should record the format error", i+1)
156+
} else {
157+
t.Fatalf("attempt %d missing response_data", i+1)
158+
}
159+
}
160+
161+
// Budget exhausted: after the schedule completes, no further attempts appear. A
162+
// dead-letter/requeue loop would keep producing attempts (or none at all) instead
163+
// of stopping cleanly at the retry budget.
164+
time.Sleep(2 * time.Second)
165+
var finalResp struct {
166+
Models []map[string]any `json:"models"`
167+
}
168+
client.doJSON(t, http.MethodGet, attemptsURL, nil, &finalResp)
169+
require.Len(t, finalResp.Models, 3,
170+
"should have exactly 3 attempts (1 initial + 2 retries) then stop — not requeue into a DLQ")
171+
172+
// (1) nothing was ever written to S3 — the failure happened before any PutObject.
173+
listed, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
174+
require.NoError(t, err)
175+
require.Empty(t, listed.Contents, "no object should have been written to S3")
176+
}

internal/destregistry/error.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ func NewErrDestinationPublishAttempt(err error, provider string, data map[string
3939
return &ErrDestinationPublishAttempt{Err: err, Provider: provider, Data: data}
4040
}
4141

42+
// NewFormatError returns the (*Delivery, error) a publisher should return when
43+
// formatting an event fails before it can be sent (e.g. an invalid key/partition
44+
// template or an unparseable payload). It records a failed attempt so the failure
45+
// is visible to the customer and the message is acked, instead of nacking into the DLQ.
46+
//
47+
// message is the customer-facing string persisted on the attempt (ResponseData);
48+
// when empty a generic default is used. The raw err is carried only in the returned
49+
// error (for logs/telemetry) and is not persisted on the attempt.
50+
func NewFormatError(provider, message string, err error) (*Delivery, error) {
51+
if message == "" {
52+
message = "could not format event for delivery"
53+
}
54+
return &Delivery{
55+
Status: "failed",
56+
Code: "ERR",
57+
Response: map[string]interface{}{"error": message},
58+
}, NewErrDestinationPublishAttempt(err, provider, map[string]interface{}{
59+
"error": "format_failed",
60+
})
61+
}
62+
4263
// NewErrPublishCanceled creates an error for when publish is canceled (e.g., service shutdown).
4364
// This should return nil Delivery to trigger nack → requeue for another instance.
4465
// See: https://github.com/hookdeck/outpost/issues/571
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package destregistry_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/hookdeck/outpost/internal/destregistry"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestNewFormatError(t *testing.T) {
13+
t.Parallel()
14+
15+
rawErr := errors.New("failed to evaluate key template: invalid type")
16+
17+
t.Run("returns a failed delivery and a publish-attempt error", func(t *testing.T) {
18+
t.Parallel()
19+
20+
delivery, err := destregistry.NewFormatError("aws_s3", "", rawErr)
21+
22+
// A non-nil failed delivery means the registry records an attempt and acks
23+
// the message instead of nacking it into the DLQ.
24+
require.NotNil(t, delivery)
25+
assert.Equal(t, "failed", delivery.Status)
26+
assert.Equal(t, "ERR", delivery.Code)
27+
28+
var pubErr *destregistry.ErrDestinationPublishAttempt
29+
require.ErrorAs(t, err, &pubErr)
30+
assert.Equal(t, "aws_s3", pubErr.Provider)
31+
assert.Equal(t, "format_failed", pubErr.Data["error"])
32+
// Raw Go error is carried on the error for logs/telemetry...
33+
assert.Equal(t, rawErr, pubErr.Err)
34+
})
35+
36+
t.Run("uses a generic message when none is given", func(t *testing.T) {
37+
t.Parallel()
38+
39+
delivery, _ := destregistry.NewFormatError("aws_s3", "", rawErr)
40+
41+
// ...but is NOT persisted on the attempt; the customer-facing response is generic.
42+
assert.Equal(t, "could not format event for delivery", delivery.Response["error"])
43+
assert.NotContains(t, delivery.Response["error"], "key template")
44+
})
45+
46+
t.Run("uses the provided message when given", func(t *testing.T) {
47+
t.Parallel()
48+
49+
delivery, _ := destregistry.NewFormatError("aws_s3", "could not build S3 object key", rawErr)
50+
assert.Equal(t, "could not build S3 object key", delivery.Response["error"])
51+
})
52+
}

internal/destregistry/providers/destawskinesis/destawskinesis.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,14 +258,7 @@ func (p *AWSKinesisPublisher) Publish(ctx context.Context, event *models.Event)
258258
// Format the event into a PutRecordInput
259259
input, err := p.Format(ctx, event)
260260
if err != nil {
261-
return nil, destregistry.NewErrDestinationPublishAttempt(
262-
err,
263-
"aws_kinesis",
264-
map[string]interface{}{
265-
"error": "format_failed",
266-
"message": err.Error(),
267-
},
268-
)
261+
return destregistry.NewFormatError("aws_kinesis", "", err)
269262
}
270263

271264
// Send the record to Kinesis

internal/destregistry/providers/destawss3/destawss3.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ func (p *AWSS3Publisher) Publish(ctx context.Context, event *models.Event) (*des
304304

305305
input, err := p.Format(ctx, event)
306306
if err != nil {
307-
return nil, err
307+
return destregistry.NewFormatError("aws_s3", "", err)
308308
}
309309

310310
_, err = p.client.PutObject(ctx, input)

internal/destregistry/providers/destawss3/destawss3_format_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,42 @@ func TestAWSS3Publisher_Format_NilResult(t *testing.T) {
198198
assert.Contains(t, err.Error(), "nil result")
199199
}
200200

201+
// TestAWSS3Publisher_Publish_FormatError reproduces the production incident where a
202+
// key_template references a field absent from the event (here metadata.operationId on
203+
// an event with no such metadata). The key cannot be built, so Format fails. Publish
204+
// must surface this as a failed delivery + ErrDestinationPublishAttempt (so the registry
205+
// records an attempt and acks) rather than a nil delivery (which nacks into the DLQ).
206+
func TestAWSS3Publisher_Publish_FormatError(t *testing.T) {
207+
event := models.Event{
208+
ID: "event-123",
209+
Time: time.Now(),
210+
Data: json.RawMessage(`{}`),
211+
}
212+
213+
// Mirrors the incident template: join over a missing metadata field yields nil,
214+
// which join rejects. Reaches Publish's Format step before any S3 client call,
215+
// so a nil client is safe here.
216+
template := `join('/', ['prefix', metadata.operationId])`
217+
publisher := destawss3.NewAWSS3Publisher(
218+
destregistry.NewBasePublisher(),
219+
nil,
220+
"my-bucket",
221+
template,
222+
"STANDARD",
223+
)
224+
225+
delivery, err := publisher.Publish(context.Background(), &event)
226+
227+
require.NotNil(t, delivery, "format failure must yield a non-nil delivery so the message is acked, not DLQ'd")
228+
assert.Equal(t, "failed", delivery.Status)
229+
assert.Equal(t, "ERR", delivery.Code)
230+
231+
var pubErr *destregistry.ErrDestinationPublishAttempt
232+
require.ErrorAs(t, err, &pubErr)
233+
assert.Equal(t, "aws_s3", pubErr.Provider)
234+
assert.Equal(t, "format_failed", pubErr.Data["error"])
235+
}
236+
201237
func TestAWSS3Publisher_Format_EmptyResult(t *testing.T) {
202238
event := models.Event{
203239
ID: "event-123",

internal/destregistry/providers/destawssqs/destawssqs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func (p *AWSSQSPublisher) Publish(ctx context.Context, event *models.Event) (*de
163163

164164
msg, err := p.Format(ctx, event)
165165
if err != nil {
166-
return nil, err
166+
return destregistry.NewFormatError("aws_sqs", "", err)
167167
}
168168

169169
if _, err = p.client.SendMessage(ctx, msg); err != nil {

internal/destregistry/providers/destazureservicebus/destazureservicebus.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func (p *AzureServiceBusPublisher) Publish(ctx context.Context, event *models.Ev
149149

150150
message, err := p.Format(ctx, event)
151151
if err != nil {
152-
return nil, err
152+
return destregistry.NewFormatError("azure_servicebus", "", err)
153153
}
154154

155155
sender, err := p.ensureSender()

internal/destregistry/providers/destgcppubsub/destgcppubsub.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func (pub *GCPPubSubPublisher) Publish(ctx context.Context, event *models.Event)
184184
// Format the message
185185
msg, err := pub.Format(ctx, event)
186186
if err != nil {
187-
return nil, err
187+
return destregistry.NewFormatError("gcp_pubsub", "", err)
188188
}
189189

190190
// Publish the message

internal/destregistry/providers/destkafka/destkafka.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,7 @@ func (p *KafkaPublisher) Publish(ctx context.Context, event *models.Event) (*des
222222
// Build parsed payload for partition key JMESPath evaluation
223223
dataMap, err := event.ParsedData()
224224
if err != nil {
225-
return nil, destregistry.NewErrDestinationPublishAttempt(
226-
err, "kafka", map[string]interface{}{
227-
"error": "format_failed",
228-
"message": err.Error(),
229-
},
230-
)
225+
return destregistry.NewFormatError("kafka", "", err)
231226
}
232227
if dataMap == nil {
233228
dataMap = make(map[string]interface{})

0 commit comments

Comments
 (0)