Skip to content

Commit 003650f

Browse files
alexluongclaude
andauthored
fix(destregistry): handle publisher creation errors as delivery failures (#828)
When CreatePublisher fails (e.g. invalid GCP credentials), the error was treated as a PreDeliveryError which caused a nack/retry loop until messages hit the Pub/Sub DLQ — invisible to the customer. Now: - GCP Pub/Sub CreatePublisher returns ErrDestinationPublishAttempt for validation and client creation errors - Registry creates a failed attempt when ResolvePublisher returns ErrDestinationPublishAttempt or ErrDestinationValidation - GCP credential JSON is validated for required 'type' field at destination creation time Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5f5bd2 commit 003650f

4 files changed

Lines changed: 179 additions & 2 deletions

File tree

internal/destregistry/providers/destgcppubsub/destgcppubsub.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ func (d *GCPPubSubDestination) Validate(ctx context.Context, destination *models
5353
func (d *GCPPubSubDestination) CreatePublisher(ctx context.Context, destination *models.Destination) (destregistry.Publisher, error) {
5454
cfg, creds, err := d.resolveMetadata(ctx, destination)
5555
if err != nil {
56-
return nil, err
56+
return nil, destregistry.NewErrDestinationPublishAttempt(err, "gcp_pubsub", map[string]interface{}{
57+
"error": "validation_failed",
58+
"message": err.Error(),
59+
})
5760
}
5861

5962
// Create Pub/Sub client options
@@ -74,7 +77,10 @@ func (d *GCPPubSubDestination) CreatePublisher(ctx context.Context, destination
7477
// Create the client
7578
client, err := pubsub.NewClient(ctx, cfg.ProjectID, opts...)
7679
if err != nil {
77-
return nil, fmt.Errorf("failed to create Pub/Sub client: %w", err)
80+
return nil, destregistry.NewErrDestinationPublishAttempt(err, "gcp_pubsub", map[string]interface{}{
81+
"error": "client_creation_failed",
82+
"message": err.Error(),
83+
})
7884
}
7985

8086
// Get the topic
@@ -108,6 +114,15 @@ func (d *GCPPubSubDestination) resolveMetadata(ctx context.Context, destination
108114
},
109115
})
110116
}
117+
// Validate required GCP credential fields
118+
if _, ok := jsonCheck["type"]; !ok {
119+
return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
120+
{
121+
Field: "credentials.service_account_json",
122+
Type: "missing_type",
123+
},
124+
})
125+
}
111126
}
112127

113128
return &GCPPubSubDestinationConfig{

internal/destregistry/providers/destgcppubsub/destgcppubsub_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,30 @@ func TestValidate(t *testing.T) {
179179
},
180180
wantErr: false, // Emulator doesn't require valid JSON
181181
},
182+
{
183+
name: "missing type field in service_account_json",
184+
config: map[string]string{
185+
"project_id": "my-project",
186+
"topic": "my-topic",
187+
},
188+
credentials: map[string]string{
189+
"service_account_json": `{"project_id":"my-project","client_email":"test@test.iam.gserviceaccount.com"}`,
190+
},
191+
wantErr: true,
192+
errContains: "credentials.service_account_json",
193+
},
194+
{
195+
name: "missing type field - but passes with emulator endpoint",
196+
config: map[string]string{
197+
"project_id": "my-project",
198+
"topic": "my-topic",
199+
"endpoint": "http://localhost:8085",
200+
},
201+
credentials: map[string]string{
202+
"service_account_json": `{"project_id":"my-project"}`,
203+
},
204+
wantErr: false,
205+
},
182206
{
183207
name: "valid service account JSON with complete structure",
184208
config: map[string]string{

internal/destregistry/registry.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ func (r *registry) ValidateDestination(ctx context.Context, destination *models.
138138
func (r *registry) PublishEvent(ctx context.Context, destination *models.Destination, event *models.Event) (*models.Attempt, error) {
139139
publisher, err := r.ResolvePublisher(ctx, destination)
140140
if err != nil {
141+
// If the provider already signaled a delivery error, create a failed attempt
142+
// so it's visible to the customer (instead of silently nacking into DLQ).
143+
var pubErr *ErrDestinationPublishAttempt
144+
var valErr *ErrDestinationValidation
145+
if errors.As(err, &pubErr) || errors.As(err, &valErr) {
146+
attempt := &models.Attempt{
147+
ID: idgen.Attempt(),
148+
DestinationID: destination.ID,
149+
DestinationType: destination.Type,
150+
EventID: event.ID,
151+
Time: time.Now(),
152+
Status: "failed",
153+
Code: "ERR",
154+
ResponseData: map[string]interface{}{"error": err.Error()},
155+
}
156+
if pubErr != nil {
157+
return attempt, pubErr
158+
}
159+
return attempt, &ErrDestinationPublishAttempt{
160+
Err: err,
161+
Provider: destination.Type,
162+
Data: map[string]interface{}{"error": "validation_failed", "message": err.Error()},
163+
}
164+
}
141165
return nil, err
142166
}
143167

internal/destregistry/registry_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,120 @@ func TestPublishEventCanceled(t *testing.T) {
773773
})
774774
}
775775

776+
func TestPublishEventResolvePublisherError(t *testing.T) {
777+
t.Parallel()
778+
779+
t.Run("should return failed attempt when CreatePublisher returns ErrDestinationPublishAttempt", func(t *testing.T) {
780+
t.Parallel()
781+
782+
registry := destregistry.NewRegistry(&destregistry.Config{}, testutil.CreateTestLogger(t))
783+
784+
provider := &mockFailingProvider{
785+
createErr: destregistry.NewErrDestinationPublishAttempt(
786+
errors.New("missing 'type' field in credentials"),
787+
"gcp_pubsub",
788+
map[string]interface{}{"error": "client_creation_failed", "message": "missing 'type' field in credentials"},
789+
),
790+
}
791+
err := registry.RegisterProvider("gcp_pubsub", provider)
792+
require.NoError(t, err)
793+
794+
destination := &models.Destination{ID: "dest-1", Type: "gcp_pubsub"}
795+
event := &models.Event{ID: "evt-1"}
796+
797+
attempt, err := registry.PublishEvent(context.Background(), destination, event)
798+
799+
require.Error(t, err)
800+
require.NotNil(t, attempt, "should return a failed attempt, not nil")
801+
assert.Equal(t, "failed", attempt.Status)
802+
assert.Equal(t, "ERR", attempt.Code)
803+
assert.Equal(t, "dest-1", attempt.DestinationID)
804+
assert.Equal(t, "evt-1", attempt.EventID)
805+
806+
var pubErr *destregistry.ErrDestinationPublishAttempt
807+
require.ErrorAs(t, err, &pubErr)
808+
assert.Equal(t, "client_creation_failed", pubErr.Data["error"])
809+
})
810+
811+
t.Run("should return failed attempt when CreatePublisher returns ErrDestinationValidation", func(t *testing.T) {
812+
t.Parallel()
813+
814+
registry := destregistry.NewRegistry(&destregistry.Config{}, testutil.CreateTestLogger(t))
815+
816+
provider := &mockFailingProvider{
817+
createErr: destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{
818+
{Field: "credentials.service_account_json", Type: "missing_type"},
819+
}),
820+
}
821+
err := registry.RegisterProvider("gcp_pubsub", provider)
822+
require.NoError(t, err)
823+
824+
destination := &models.Destination{ID: "dest-2", Type: "gcp_pubsub"}
825+
event := &models.Event{ID: "evt-2"}
826+
827+
attempt, err := registry.PublishEvent(context.Background(), destination, event)
828+
829+
require.Error(t, err)
830+
require.NotNil(t, attempt, "should return a failed attempt, not nil")
831+
assert.Equal(t, "failed", attempt.Status)
832+
assert.Equal(t, "ERR", attempt.Code)
833+
834+
var pubErr *destregistry.ErrDestinationPublishAttempt
835+
require.ErrorAs(t, err, &pubErr)
836+
assert.Equal(t, "validation_failed", pubErr.Data["error"])
837+
})
838+
839+
t.Run("should return nil attempt for unknown errors", func(t *testing.T) {
840+
t.Parallel()
841+
842+
registry := destregistry.NewRegistry(&destregistry.Config{}, testutil.CreateTestLogger(t))
843+
844+
provider := &mockFailingProvider{
845+
createErr: errors.New("unexpected system error"),
846+
}
847+
err := registry.RegisterProvider("gcp_pubsub", provider)
848+
require.NoError(t, err)
849+
850+
destination := &models.Destination{ID: "dest-3", Type: "gcp_pubsub"}
851+
event := &models.Event{ID: "evt-3"}
852+
853+
attempt, err := registry.PublishEvent(context.Background(), destination, event)
854+
855+
require.Error(t, err)
856+
assert.Nil(t, attempt, "should return nil attempt for unknown errors")
857+
})
858+
}
859+
860+
// mockFailingProvider simulates a provider whose CreatePublisher always fails
861+
type mockFailingProvider struct {
862+
createErr error
863+
*destregistry.BaseProvider
864+
}
865+
866+
func (p *mockFailingProvider) Validate(ctx context.Context, dest *models.Destination) error {
867+
return nil
868+
}
869+
870+
func (p *mockFailingProvider) CreatePublisher(ctx context.Context, dest *models.Destination) (destregistry.Publisher, error) {
871+
return nil, p.createErr
872+
}
873+
874+
func (p *mockFailingProvider) ComputeTarget(dest *models.Destination) destregistry.DestinationTarget {
875+
return destregistry.DestinationTarget{}
876+
}
877+
878+
func (p *mockFailingProvider) Metadata() *metadata.ProviderMetadata {
879+
return &metadata.ProviderMetadata{Type: "gcp_pubsub"}
880+
}
881+
882+
func (p *mockFailingProvider) ObfuscateDestination(dest *models.Destination) *models.Destination {
883+
return dest
884+
}
885+
886+
func (p *mockFailingProvider) Preprocess(newDest *models.Destination, origDest *models.Destination, opts *destregistry.PreprocessDestinationOpts) error {
887+
return nil
888+
}
889+
776890
func TestDisplayDestination(t *testing.T) {
777891
t.Parallel()
778892

0 commit comments

Comments
 (0)