init push
This commit is contained in:
356
internal/repository/sqs/email_queue.go
Normal file
356
internal/repository/sqs/email_queue.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
||||
|
||||
appconfig "wucher/internal/config"
|
||||
"wucher/internal/shared/pkg/metrics"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
SendMessage(ctx context.Context, params *sqs.SendMessageInput, optFns ...func(*sqs.Options)) (*sqs.SendMessageOutput, error)
|
||||
ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error)
|
||||
DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error)
|
||||
ChangeMessageVisibility(ctx context.Context, params *sqs.ChangeMessageVisibilityInput, optFns ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error)
|
||||
GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error)
|
||||
}
|
||||
|
||||
type EmailQueue struct {
|
||||
api API
|
||||
queueURL string
|
||||
queueName string
|
||||
component string
|
||||
queueType string
|
||||
messageGroupID string
|
||||
maxMessages int
|
||||
waitTimeSeconds int32
|
||||
visibilityTimeout int32
|
||||
}
|
||||
|
||||
type QueueOption func(*EmailQueue)
|
||||
|
||||
func WithMetricsComponent(component string) QueueOption {
|
||||
return func(q *EmailQueue) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
component = strings.TrimSpace(component)
|
||||
if component == "" {
|
||||
return
|
||||
}
|
||||
q.component = component
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, cfg appconfig.SQSConfig) (*sqs.Client, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 1)
|
||||
if strings.TrimSpace(cfg.Region) != "" {
|
||||
loadOptions = append(loadOptions, awsconfig.WithRegion(strings.TrimSpace(cfg.Region)))
|
||||
}
|
||||
if key := strings.TrimSpace(cfg.AccessKeyID); key != "" {
|
||||
secret := strings.TrimSpace(cfg.SecretAccessKey)
|
||||
loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(key, secret, "")))
|
||||
}
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := sqs.NewFromConfig(awsCfg, func(options *sqs.Options) {
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
options.EndpointResolver = sqs.EndpointResolverFromURL(endpoint)
|
||||
}
|
||||
})
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func NewEmailQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) {
|
||||
return NewQueue(api, cfg, opts...)
|
||||
}
|
||||
|
||||
func NewQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if api == nil {
|
||||
return nil, errors.New("sqs client is required")
|
||||
}
|
||||
waitTimeSeconds := cfg.WaitTimeSeconds
|
||||
if waitTimeSeconds <= 0 {
|
||||
waitTimeSeconds = 20
|
||||
}
|
||||
if waitTimeSeconds > 20 {
|
||||
waitTimeSeconds = 20
|
||||
}
|
||||
maxMessages := cfg.MaxMessages
|
||||
if maxMessages <= 0 {
|
||||
maxMessages = 10
|
||||
}
|
||||
if maxMessages > 10 {
|
||||
maxMessages = 10
|
||||
}
|
||||
visibilityTimeout := int32(cfg.VisibilityTimeout.Seconds())
|
||||
if visibilityTimeout <= 0 {
|
||||
visibilityTimeout = 60
|
||||
}
|
||||
if visibilityTimeout > 43200 {
|
||||
visibilityTimeout = 43200
|
||||
}
|
||||
queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType))
|
||||
if queueType == "" {
|
||||
queueType = queue.QueueTypeStandard
|
||||
}
|
||||
queue := &EmailQueue{
|
||||
api: api,
|
||||
queueURL: strings.TrimSpace(cfg.QueueURL),
|
||||
queueName: metrics.QueueNameFromURL(cfg.QueueURL),
|
||||
component: "worker",
|
||||
queueType: queueType,
|
||||
messageGroupID: strings.TrimSpace(cfg.MessageGroupID),
|
||||
maxMessages: maxMessages,
|
||||
waitTimeSeconds: waitTimeSeconds,
|
||||
visibilityTimeout: visibilityTimeout,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(queue)
|
||||
}
|
||||
}
|
||||
metrics.InitializeQueueSeries(queue.component, queue.queueName)
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Backend() string {
|
||||
return queue.BackendSQS
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Publish(ctx context.Context, message queue.OutboundMessage) error {
|
||||
input := &sqs.SendMessageInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
MessageBody: awsv2.String(string(message.Body)),
|
||||
}
|
||||
if len(message.Attributes) > 0 {
|
||||
input.MessageAttributes = make(map[string]sqstypes.MessageAttributeValue, len(message.Attributes))
|
||||
for key, value := range message.Attributes {
|
||||
v := value
|
||||
input.MessageAttributes[key] = sqstypes.MessageAttributeValue{
|
||||
DataType: awsv2.String("String"),
|
||||
StringValue: awsv2.String(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.queueType == queue.QueueTypeFIFO {
|
||||
groupID := strings.TrimSpace(message.MessageGroupID)
|
||||
if groupID == "" {
|
||||
groupID = q.messageGroupID
|
||||
}
|
||||
if groupID == "" {
|
||||
groupID = "default"
|
||||
}
|
||||
deduplicationID := strings.TrimSpace(message.DeduplicationID)
|
||||
if deduplicationID == "" {
|
||||
deduplicationID = message.ID
|
||||
}
|
||||
input.MessageGroupId = awsv2.String(groupID)
|
||||
input.MessageDeduplicationId = awsv2.String(deduplicationID)
|
||||
}
|
||||
started := time.Now()
|
||||
_, err := q.api.SendMessage(ctx, input)
|
||||
metrics.ObserveQueueSend(q.component, q.queueName, int64(len(message.Body)), err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Receive(ctx context.Context, maxMessages int) ([]*queue.Delivery, error) {
|
||||
if maxMessages <= 0 {
|
||||
maxMessages = 1
|
||||
}
|
||||
if q.maxMessages > 0 && maxMessages > q.maxMessages {
|
||||
maxMessages = q.maxMessages
|
||||
}
|
||||
if maxMessages > 10 {
|
||||
maxMessages = 10
|
||||
}
|
||||
started := time.Now()
|
||||
output, err := q.api.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
MaxNumberOfMessages: int32(maxMessages),
|
||||
WaitTimeSeconds: q.waitTimeSeconds,
|
||||
VisibilityTimeout: q.visibilityTimeout,
|
||||
MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{
|
||||
sqstypes.MessageSystemAttributeNameApproximateReceiveCount,
|
||||
sqstypes.MessageSystemAttributeNameSentTimestamp,
|
||||
},
|
||||
MessageAttributeNames: []string{"All"},
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueReceive(q.component, q.queueName, 0, 0, err, started)
|
||||
return nil, err
|
||||
}
|
||||
var totalMessageBytes int64
|
||||
for i := range output.Messages {
|
||||
totalMessageBytes += int64(len(awsv2.ToString(output.Messages[i].Body)))
|
||||
}
|
||||
metrics.ObserveQueueReceive(q.component, q.queueName, len(output.Messages), totalMessageBytes, nil, started)
|
||||
|
||||
deliveries := make([]*queue.Delivery, 0, len(output.Messages))
|
||||
for _, message := range output.Messages {
|
||||
attributes := make(map[string]string, len(message.MessageAttributes))
|
||||
for key, value := range message.MessageAttributes {
|
||||
if value.StringValue != nil {
|
||||
attributes[key] = *value.StringValue
|
||||
}
|
||||
}
|
||||
receiveCount, _ := strconv.Atoi(message.Attributes[string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount)])
|
||||
sentAt := time.Now().UTC()
|
||||
if raw := message.Attributes[string(sqstypes.MessageSystemAttributeNameSentTimestamp)]; raw != "" {
|
||||
if millis, convErr := strconv.ParseInt(raw, 10, 64); convErr == nil {
|
||||
sentAt = time.UnixMilli(millis).UTC()
|
||||
}
|
||||
}
|
||||
body := awsv2.ToString(message.Body)
|
||||
deliveries = append(deliveries, &queue.Delivery{
|
||||
ID: awsv2.ToString(message.MessageId),
|
||||
ReceiptHandle: awsv2.ToString(message.ReceiptHandle),
|
||||
Body: []byte(body),
|
||||
Attributes: attributes,
|
||||
ReceiveCount: receiveCount,
|
||||
EnqueuedAt: sentAt,
|
||||
})
|
||||
}
|
||||
return deliveries, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Ack(ctx context.Context, deliveries []*queue.Delivery) error {
|
||||
if len(deliveries) == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := make([]sqstypes.DeleteMessageBatchRequestEntry, 0, len(deliveries))
|
||||
for i, delivery := range deliveries {
|
||||
if delivery == nil || strings.TrimSpace(delivery.ReceiptHandle) == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, sqstypes.DeleteMessageBatchRequestEntry{
|
||||
Id: awsv2.String(strconv.Itoa(i)),
|
||||
ReceiptHandle: awsv2.String(delivery.ReceiptHandle),
|
||||
})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
output, err := q.api.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
Entries: entries,
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, 0, err, started)
|
||||
return err
|
||||
}
|
||||
if len(output.Failed) > 0 {
|
||||
batchErr := errors.New("sqs batch delete reported failures")
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), batchErr, started)
|
||||
return batchErr
|
||||
}
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), nil, started)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Retry(ctx context.Context, delivery *queue.Delivery, delay time.Duration) error {
|
||||
if delivery == nil {
|
||||
return nil
|
||||
}
|
||||
seconds := int32(delay.Seconds())
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
if seconds > 43200 {
|
||||
seconds = 43200
|
||||
}
|
||||
started := time.Now()
|
||||
_, err := q.api.ChangeMessageVisibility(ctx, &sqs.ChangeMessageVisibilityInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
ReceiptHandle: awsv2.String(delivery.ReceiptHandle),
|
||||
VisibilityTimeout: seconds,
|
||||
})
|
||||
metrics.ObserveQueueVisibilityChange(q.component, q.queueName, err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *EmailQueue) DeadLetter(ctx context.Context, delivery *queue.Delivery, reason string, cause error) error {
|
||||
// Leave the message unacked so SQS redrive policy can move it to the queue DLQ.
|
||||
_ = ctx
|
||||
_ = delivery
|
||||
_ = reason
|
||||
_ = cause
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) ApproximateDepth(ctx context.Context) (int64, error) {
|
||||
started := time.Now()
|
||||
output, err := q.api.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
AttributeNames: []sqstypes.QueueAttributeName{
|
||||
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
|
||||
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueDepth(q.component, q.queueName, 0, err, started)
|
||||
return 0, err
|
||||
}
|
||||
visible, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessages)], 10, 64)
|
||||
inFlight, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible)], 10, 64)
|
||||
depth := visible + inFlight
|
||||
metrics.ObserveQueueDepth(q.component, q.queueName, depth, nil, started)
|
||||
return depth, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg appconfig.SQSConfig) error {
|
||||
if strings.TrimSpace(cfg.QueueURL) == "" {
|
||||
return errors.New("sqs queue url is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Region) == "" {
|
||||
return errors.New("aws region is required")
|
||||
}
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
|
||||
return errors.New("insecure sqs endpoint is disabled")
|
||||
}
|
||||
}
|
||||
queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType))
|
||||
if queueType == "" {
|
||||
queueType = queue.QueueTypeStandard
|
||||
}
|
||||
switch queueType {
|
||||
case queue.QueueTypeStandard:
|
||||
case queue.QueueTypeFIFO:
|
||||
if strings.TrimSpace(cfg.MessageGroupID) == "" {
|
||||
return errors.New("sqs message group id is required for fifo queues")
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported sqs queue type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
236
internal/repository/sqs/email_queue_test.go
Normal file
236
internal/repository/sqs/email_queue_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/queue"
|
||||
"wucher/internal/resilience"
|
||||
)
|
||||
|
||||
type mockAPI struct {
|
||||
sendInput *sqs.SendMessageInput
|
||||
receiveInput *sqs.ReceiveMessageInput
|
||||
deleteBatchInput *sqs.DeleteMessageBatchInput
|
||||
changeVisibilityInput *sqs.ChangeMessageVisibilityInput
|
||||
getAttributesInput *sqs.GetQueueAttributesInput
|
||||
sendErr error
|
||||
receiveErr error
|
||||
deleteBatchErr error
|
||||
changeVisibilityErr error
|
||||
getAttributesErr error
|
||||
sendCalls int
|
||||
receiveCalls int
|
||||
}
|
||||
|
||||
func (m *mockAPI) SendMessage(_ context.Context, params *sqs.SendMessageInput, _ ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) {
|
||||
m.sendCalls++
|
||||
m.sendInput = params
|
||||
if m.sendErr != nil {
|
||||
return nil, m.sendErr
|
||||
}
|
||||
return &sqs.SendMessageOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) ReceiveMessage(_ context.Context, params *sqs.ReceiveMessageInput, _ ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) {
|
||||
m.receiveCalls++
|
||||
m.receiveInput = params
|
||||
if m.receiveErr != nil {
|
||||
return nil, m.receiveErr
|
||||
}
|
||||
return &sqs.ReceiveMessageOutput{
|
||||
Messages: []sqstypes.Message{{
|
||||
MessageId: awsv2.String("msg-1"),
|
||||
ReceiptHandle: awsv2.String("receipt-1"),
|
||||
Body: awsv2.String(`{"version":"v1","message_id":"msg-1","kind":"email.dispatch","occurred_at":"2026-03-19T00:00:00Z","payload":{"to":"user@example.com","subject":"Hello","body":"World"}}`),
|
||||
MessageAttributes: map[string]sqstypes.MessageAttributeValue{
|
||||
"correlation_id": {DataType: awsv2.String("String"), StringValue: awsv2.String("req-1")},
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount): "3",
|
||||
string(sqstypes.MessageSystemAttributeNameSentTimestamp): "1710806400000",
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) DeleteMessageBatch(_ context.Context, params *sqs.DeleteMessageBatchInput, _ ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
m.deleteBatchInput = params
|
||||
if m.deleteBatchErr != nil {
|
||||
return nil, m.deleteBatchErr
|
||||
}
|
||||
return &sqs.DeleteMessageBatchOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) ChangeMessageVisibility(_ context.Context, params *sqs.ChangeMessageVisibilityInput, _ ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
m.changeVisibilityInput = params
|
||||
if m.changeVisibilityErr != nil {
|
||||
return nil, m.changeVisibilityErr
|
||||
}
|
||||
return &sqs.ChangeMessageVisibilityOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) GetQueueAttributes(_ context.Context, params *sqs.GetQueueAttributesInput, _ ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) {
|
||||
m.getAttributesInput = params
|
||||
if m.getAttributesErr != nil {
|
||||
return nil, m.getAttributesErr
|
||||
}
|
||||
return &sqs.GetQueueAttributesOutput{
|
||||
Attributes: map[string]string{
|
||||
string(sqstypes.QueueAttributeNameApproximateNumberOfMessages): "5",
|
||||
string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible): "2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestEmailQueuePublishReceiveAckRetryDeadLetter(t *testing.T) {
|
||||
api := &mockAPI{}
|
||||
cfg := config.SQSConfig{
|
||||
Region: "ap-southeast-1",
|
||||
QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test",
|
||||
QueueType: queue.QueueTypeFIFO,
|
||||
MessageGroupID: "email",
|
||||
VisibilityTimeout: 30 * time.Second,
|
||||
WaitTimeSeconds: 20,
|
||||
}
|
||||
q, err := NewEmailQueue(api, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new queue: %v", err)
|
||||
}
|
||||
|
||||
err = q.Publish(context.Background(), queue.OutboundMessage{
|
||||
ID: "msg-1",
|
||||
Body: []byte(`{"hello":"world"}`),
|
||||
Attributes: map[string]string{"correlation_id": "req-1"},
|
||||
MessageGroupID: "email",
|
||||
DeduplicationID: "dedup-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if api.sendInput == nil || awsv2.ToString(api.sendInput.MessageGroupId) != "email" {
|
||||
t.Fatalf("expected fifo publish settings")
|
||||
}
|
||||
|
||||
deliveries, err := q.Receive(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
if len(deliveries) != 1 || deliveries[0].ReceiveCount != 3 {
|
||||
t.Fatalf("unexpected deliveries: %+v", deliveries)
|
||||
}
|
||||
|
||||
if err := q.Ack(context.Background(), deliveries); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
if api.deleteBatchInput == nil || len(api.deleteBatchInput.Entries) != 1 {
|
||||
t.Fatalf("expected batch delete")
|
||||
}
|
||||
|
||||
if err := q.Retry(context.Background(), deliveries[0], 45*time.Second); err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if api.changeVisibilityInput == nil || api.changeVisibilityInput.VisibilityTimeout != 45 {
|
||||
t.Fatalf("expected retry visibility timeout 45")
|
||||
}
|
||||
|
||||
if err := q.DeadLetter(context.Background(), deliveries[0], "permanent failure", nil); err != nil {
|
||||
t.Fatalf("dead-letter: %v", err)
|
||||
}
|
||||
if got := awsv2.ToString(api.sendInput.MessageBody); got != `{"hello":"world"}` {
|
||||
t.Fatalf("expected dead-letter path to leave message managed by SQS redrive, got send body %q", got)
|
||||
}
|
||||
|
||||
depth, err := q.ApproximateDepth(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("depth: %v", err)
|
||||
}
|
||||
if depth != 7 {
|
||||
t.Fatalf("expected depth 7, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigRejectsInsecureEndpoint(t *testing.T) {
|
||||
err := validateConfig(config.SQSConfig{
|
||||
Region: "ap-southeast-1",
|
||||
QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test",
|
||||
Endpoint: "http://localhost:4566",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected insecure endpoint validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResilientAPI_CircuitBreaker(t *testing.T) {
|
||||
newExecutor := func() *resilience.Executor {
|
||||
return resilience.NewExecutor("sqs", config.CircuitBreakerPolicy{
|
||||
Enabled: true,
|
||||
Timeout: time.Second,
|
||||
MinRequests: 2,
|
||||
FailureRatio: 0.5,
|
||||
ConsecutiveFailures: 2,
|
||||
}, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifySQSError)
|
||||
}
|
||||
|
||||
t.Run("client faults are excluded", func(t *testing.T) {
|
||||
api := &mockAPI{
|
||||
sendErr: sqsAPIError{code: "AccessDenied", fault: smithy.FaultClient},
|
||||
}
|
||||
wrapped := NewResilientAPI(api, newExecutor())
|
||||
|
||||
for range 3 {
|
||||
_, err := wrapped.SendMessage(context.Background(), &sqs.SendMessageInput{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected access denied error")
|
||||
}
|
||||
var openErr *resilience.OpenError
|
||||
if errors.As(err, &openErr) {
|
||||
t.Fatalf("expected excluded error to keep breaker closed, got %v", err)
|
||||
}
|
||||
}
|
||||
if api.sendCalls != 3 {
|
||||
t.Fatalf("expected all excluded calls to reach API, got %d", api.sendCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server faults open the breaker", func(t *testing.T) {
|
||||
api := &mockAPI{
|
||||
receiveErr: sqsAPIError{code: "InternalError", fault: smithy.FaultServer},
|
||||
}
|
||||
wrapped := NewResilientAPI(api, newExecutor())
|
||||
|
||||
for range 2 {
|
||||
if _, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{}); err == nil {
|
||||
t.Fatalf("expected receive error")
|
||||
}
|
||||
}
|
||||
_, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{})
|
||||
var openErr *resilience.OpenError
|
||||
if !errors.As(err, &openErr) {
|
||||
t.Fatalf("expected circuit open error, got %v", err)
|
||||
}
|
||||
if api.receiveCalls != 2 {
|
||||
t.Fatalf("expected breaker to fail fast after opening, got %d receive calls", api.receiveCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type sqsAPIError struct {
|
||||
code string
|
||||
fault smithy.ErrorFault
|
||||
}
|
||||
|
||||
func (e sqsAPIError) Error() string { return e.code }
|
||||
func (e sqsAPIError) ErrorCode() string { return e.code }
|
||||
func (e sqsAPIError) ErrorMessage() string { return e.code }
|
||||
func (e sqsAPIError) ErrorFault() smithy.ErrorFault { return e.fault }
|
||||
89
internal/repository/sqs/integration_test.go
Normal file
89
internal/repository/sqs/integration_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
func TestEmailQueueIntegration(t *testing.T) {
|
||||
queueURL := os.Getenv("SQS_INTEGRATION_QUEUE_URL")
|
||||
if queueURL == "" {
|
||||
t.Skip("SQS_INTEGRATION_QUEUE_URL is not set")
|
||||
}
|
||||
|
||||
cfg := config.SQSConfig{
|
||||
Region: getenv("SQS_INTEGRATION_REGION", os.Getenv("AWS_REGION")),
|
||||
Endpoint: getenv("SQS_INTEGRATION_ENDPOINT", os.Getenv("SQS_ENDPOINT")),
|
||||
QueueURL: queueURL,
|
||||
QueueType: getenv("SQS_INTEGRATION_QUEUE_TYPE", queue.QueueTypeStandard),
|
||||
MessageGroupID: getenv("SQS_INTEGRATION_MESSAGE_GROUP_ID", "integration"),
|
||||
MaxMessages: 1,
|
||||
WaitTimeSeconds: 5,
|
||||
VisibilityTimeout: 30 * time.Second,
|
||||
AllowInsecureEndpoint: os.Getenv("SQS_INTEGRATION_ALLOW_INSECURE_ENDPOINT") == "true",
|
||||
}
|
||||
client, err := NewClient(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
adapter, err := NewEmailQueue(client, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new queue: %v", err)
|
||||
}
|
||||
|
||||
serializer := queue.NewJSONSerializer("v1", true, cfg.QueueType, cfg.MessageGroupID)
|
||||
outbound, err := serializer.Serialize(context.Background(), queue.EmailJob{
|
||||
Type: "integration",
|
||||
To: "integration@example.com",
|
||||
Subject: "integration-" + time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
if err := adapter.Publish(context.Background(), *outbound); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
deliveries, err := adapter.Receive(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("timed out waiting for message")
|
||||
}
|
||||
continue
|
||||
}
|
||||
envelope, err := serializer.Deserialize(deliveries[0].Body, deliveries[0].Attributes)
|
||||
if err != nil {
|
||||
t.Fatalf("deserialize: %v", err)
|
||||
}
|
||||
if envelope.MessageID != outbound.ID {
|
||||
if err := adapter.Retry(context.Background(), deliveries[0], 0); err != nil {
|
||||
t.Fatalf("retry unrelated message: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := adapter.Ack(context.Background(), deliveries); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
54
internal/repository/sqs/resilient_api.go
Normal file
54
internal/repository/sqs/resilient_api.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
|
||||
"wucher/internal/resilience"
|
||||
)
|
||||
|
||||
type resilientAPI struct {
|
||||
next API
|
||||
executor *resilience.Executor
|
||||
}
|
||||
|
||||
func NewResilientAPI(next API, executor *resilience.Executor) API {
|
||||
if next == nil || executor == nil || !executor.Enabled() {
|
||||
return next
|
||||
}
|
||||
return &resilientAPI{
|
||||
next: next,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resilientAPI) SendMessage(ctx context.Context, params *sqs.SendMessageInput, optFns ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.SendMessageOutput, error) {
|
||||
return r.next.SendMessage(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.ReceiveMessageOutput, error) {
|
||||
return r.next.ReceiveMessage(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
return r.next.DeleteMessageBatch(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) ChangeMessageVisibility(ctx context.Context, params *sqs.ChangeMessageVisibilityInput, optFns ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
return r.next.ChangeMessageVisibility(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.GetQueueAttributesOutput, error) {
|
||||
return r.next.GetQueueAttributes(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user