140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
package nats
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// validateDLQ verifies whether DLQ routing should be enabled and, if so, whether
|
|
// the configured DLQ subject is bound to a JetStream stream. Returns an error
|
|
// if DLQ is enabled but misconfigured, allowing the caller to fail fast.
|
|
func (c *Consumer) validateDLQ() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
// DLQ routing is only active in JetStream mode.
|
|
if c.config.mode != "jetstream" {
|
|
return nil
|
|
}
|
|
|
|
// If DLQ is not enabled in config, make sure we don't accidentally route to it.
|
|
if !c.cfg.DLQ.Enabled {
|
|
if strings.TrimSpace(c.config.dlqSubject) != "" {
|
|
c.logger.Info("DLQ subject configured but dlq.enabled is false; DLQ routing disabled",
|
|
zap.String("dlq_subject", c.config.dlqSubject),
|
|
)
|
|
}
|
|
c.config.dlqSubject = ""
|
|
return nil
|
|
}
|
|
|
|
subject := strings.TrimSpace(c.config.dlqSubject)
|
|
if subject == "" {
|
|
return fmt.Errorf("DLQ enabled but dlq.subject is empty")
|
|
}
|
|
|
|
if c.js == nil {
|
|
return fmt.Errorf("DLQ enabled but JetStream context is nil")
|
|
}
|
|
|
|
// Ensure the DLQ subject is actually bound to a JetStream stream. This avoids
|
|
// the opaque `nats: no response from stream` error later when publishing.
|
|
c.telemetry.RecordJSAPICall("dlq_validate_stream")
|
|
streamName, err := c.js.StreamNameBySubject(subject)
|
|
if err != nil || strings.TrimSpace(streamName) == "" {
|
|
return fmt.Errorf("DLQ subject %s not bound to any JetStream stream: %w", subject, err)
|
|
}
|
|
|
|
c.logger.Info("DLQ configuration validated",
|
|
zap.String("dlq_subject", subject),
|
|
zap.String("dlq_stream", streamName),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// routeToDLQ publishes a copy of the failed message to the configured DLQ subject,
|
|
// including useful metadata for offline analysis. If DLQ is not configured or the
|
|
// consumer is not running in JetStream mode, this is a no-op.
|
|
func (c *Consumer) routeToDLQ(ctx context.Context, msg *nats.Msg, cause error) error {
|
|
if c == nil || c.js == nil {
|
|
return nil
|
|
}
|
|
if c.config.mode != "jetstream" {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(c.config.dlqSubject) == "" {
|
|
return nil
|
|
}
|
|
|
|
meta, _ := msg.Metadata()
|
|
jsSeq := uint64(0)
|
|
deliveries := uint64(0)
|
|
if meta != nil {
|
|
jsSeq = meta.Sequence.Stream
|
|
deliveries = meta.NumDelivered
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"transport_msg_id": msg.Header.Get("Nats-Msg-Id"),
|
|
"subject": msg.Subject,
|
|
"stream": c.config.streamName,
|
|
"consumer": c.config.consumerName,
|
|
"nats_sequence": jsSeq,
|
|
"deliveries": deliveries,
|
|
"error": fmt.Sprint(cause),
|
|
"received_at": time.Now().UTC(),
|
|
"body": string(msg.Data),
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
c.logger.Error("failed to marshal DLQ payload",
|
|
zap.String("stream", c.config.streamName),
|
|
zap.String("consumer", c.config.consumerName),
|
|
zap.String("dlq_subject", c.config.dlqSubject),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("marshal dlq payload: %w", err)
|
|
}
|
|
|
|
if _, err := c.js.Publish(c.config.dlqSubject, data); err != nil {
|
|
// nats.ErrNoResponders typically means that no JetStream stream is
|
|
// configured to receive this subject, or JetStream is temporarily
|
|
// unavailable. Surface this explicitly to make operational diagnosis
|
|
// easier.
|
|
if errors.Is(err, nats.ErrNoResponders) {
|
|
c.logger.Error("transient DLQ publish error (no responders)",
|
|
zap.String("stream", c.config.streamName),
|
|
zap.String("consumer", c.config.consumerName),
|
|
zap.String("dlq_subject", c.config.dlqSubject),
|
|
zap.Int("payload_size", len(data)),
|
|
zap.Error(err),
|
|
)
|
|
c.telemetry.RecordDLQPublishFailure(ctx, c.config.streamName, c.config.consumerName)
|
|
return fmt.Errorf("publish to dlq subject %s: no JetStream stream found for subject or JetStream unavailable: %w", c.config.dlqSubject, err)
|
|
}
|
|
c.logger.Error("failed to publish to DLQ",
|
|
zap.String("stream", c.config.streamName),
|
|
zap.String("consumer", c.config.consumerName),
|
|
zap.String("dlq_subject", c.config.dlqSubject),
|
|
zap.Int("payload_size", len(data)),
|
|
zap.Error(err),
|
|
)
|
|
c.telemetry.RecordDLQPublishFailure(ctx, c.config.streamName, c.config.consumerName)
|
|
return fmt.Errorf("publish to dlq subject %s: %w", c.config.dlqSubject, err)
|
|
}
|
|
|
|
c.telemetry.RecordDLQMessage(ctx, c.config.streamName, c.config.consumerName)
|
|
|
|
return nil
|
|
}
|