Skip to content

Commit 1af1f1b

Browse files
Add DLQ writer and retryer with tests
Introduce a dead-letter queue writer (internal/connector/destination/dlq) that persists failed RoutedPayloads as timestamped JSON files. Files include timestamp, message ID, error, attempts and the original payload; the directory is auto-created and filenames are sanitized. Add a retryer (internal/connector/destination/retry) that wraps a Sender with configurable MaxAttempts, InitialBackoff and Multiplier to perform exponential-backoff retries, respects context cancellation, and exposes injectable sleep/now hooks for tests. Unit tests added for both components to validate directory/file creation, JSON contents, filename format, retry behaviour, backoff growth and defaulting.
1 parent a2896ab commit 1af1f1b

4 files changed

Lines changed: 549 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Package dlq implements a dead-letter queue that persists failed delivery
2+
// payloads to the local filesystem as JSON files.
3+
//
4+
// Each failed message is written to Dir as a timestamped JSON file containing
5+
// the full RoutedPayload, the error message, and the number of delivery
6+
// attempts. Files can be replayed manually or by an automated recovery process.
7+
package dlq
8+
9+
import (
10+
"encoding/json"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"time"
15+
16+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
17+
)
18+
19+
// Entry is the JSON structure written for each dead-lettered message.
20+
type Entry struct {
21+
Timestamp string `json:"ts"`
22+
MessageID string `json:"msg_id"`
23+
Error string `json:"error"`
24+
Attempts int `json:"attempts"`
25+
Payload model.RoutedPayload `json:"payload"`
26+
}
27+
28+
// Config controls where dead-letter files are written.
29+
type Config struct {
30+
// Dir is the directory for dead-letter JSON files.
31+
// It is created automatically if it does not exist.
32+
Dir string
33+
}
34+
35+
// Writer persists failed payloads to disk.
36+
type Writer struct {
37+
cfg Config
38+
now func() time.Time // injectable for tests
39+
}
40+
41+
// New creates a Writer. The dead-letter directory is not created until the
42+
// first Write call.
43+
func New(cfg Config) *Writer {
44+
return &Writer{cfg: cfg, now: time.Now}
45+
}
46+
47+
// SetNow replaces the time source. Used only in tests.
48+
func (w *Writer) SetNow(fn func() time.Time) { w.now = fn }
49+
50+
// Write serialises payload as a JSON dead-letter entry and writes it to Dir.
51+
// The file name is <unix-nano>-<msgID>.json, guaranteeing uniqueness.
52+
// It returns an error only if the file cannot be written; callers should log
53+
// this but must not block the pipeline on a write failure.
54+
func (w *Writer) Write(payload model.RoutedPayload, attempts int, cause error) error {
55+
if err := os.MkdirAll(w.cfg.Dir, 0o750); err != nil {
56+
return fmt.Errorf("dlq: create dir: %w", err)
57+
}
58+
59+
ts := w.now()
60+
entry := Entry{
61+
Timestamp: ts.UTC().Format(time.RFC3339),
62+
MessageID: payload.Resource.ID,
63+
Error: cause.Error(),
64+
Attempts: attempts,
65+
Payload: payload,
66+
}
67+
68+
data, err := json.MarshalIndent(entry, "", " ")
69+
if err != nil {
70+
return fmt.Errorf("dlq: marshal: %w", err)
71+
}
72+
73+
name := fmt.Sprintf("%d-%s.json", ts.UnixNano(), sanitize(payload.Resource.ID))
74+
path := filepath.Join(w.cfg.Dir, name)
75+
if err := os.WriteFile(path, data, 0o640); err != nil {
76+
return fmt.Errorf("dlq: write file: %w", err)
77+
}
78+
return nil
79+
}
80+
81+
// sanitize removes characters unsafe for filenames.
82+
func sanitize(s string) string {
83+
out := make([]byte, 0, len(s))
84+
for i := 0; i < len(s); i++ {
85+
c := s[i]
86+
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
87+
(c >= '0' && c <= '9') || c == '-' || c == '_' {
88+
out = append(out, c)
89+
} else {
90+
out = append(out, '_')
91+
}
92+
}
93+
return string(out)
94+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package dlq_test
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/dlq"
14+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
15+
)
16+
17+
var testPayload = model.RoutedPayload{
18+
Resource: model.FHIRResource{ID: "msg-abc"},
19+
}
20+
21+
func newWriter(dir string, t time.Time) *dlq.Writer {
22+
w := dlq.New(dlq.Config{Dir: dir})
23+
w.SetNow(func() time.Time { return t })
24+
return w
25+
}
26+
27+
func TestCreatesDirectory(t *testing.T) {
28+
dir := filepath.Join(t.TempDir(), "sub", "dlq")
29+
w := newWriter(dir, time.Now())
30+
31+
if err := w.Write(testPayload, 1, errors.New("fail")); err != nil {
32+
t.Fatalf("unexpected error: %v", err)
33+
}
34+
if _, err := os.Stat(dir); os.IsNotExist(err) {
35+
t.Error("directory was not created")
36+
}
37+
}
38+
39+
func TestWritesValidJSONFile(t *testing.T) {
40+
dir := t.TempDir()
41+
w := newWriter(dir, time.Now())
42+
43+
if err := w.Write(testPayload, 2, errors.New("connection refused")); err != nil {
44+
t.Fatalf("unexpected error: %v", err)
45+
}
46+
47+
entries, err := os.ReadDir(dir)
48+
if err != nil {
49+
t.Fatalf("read dir: %v", err)
50+
}
51+
if len(entries) != 1 {
52+
t.Fatalf("expected 1 file, got %d", len(entries))
53+
}
54+
55+
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
56+
if err != nil {
57+
t.Fatalf("read file: %v", err)
58+
}
59+
60+
var entry dlq.Entry
61+
if err := json.Unmarshal(data, &entry); err != nil {
62+
t.Fatalf("unmarshal: %v", err)
63+
}
64+
}
65+
66+
func TestFileContainsMessageID(t *testing.T) {
67+
dir := t.TempDir()
68+
w := newWriter(dir, time.Now())
69+
_ = w.Write(testPayload, 1, errors.New("fail"))
70+
71+
entries, _ := os.ReadDir(dir)
72+
data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
73+
74+
var entry dlq.Entry
75+
_ = json.Unmarshal(data, &entry)
76+
77+
if entry.MessageID != testPayload.Resource.ID {
78+
t.Errorf("msg_id: want %s, got %s", testPayload.Resource.ID, entry.MessageID)
79+
}
80+
}
81+
82+
func TestFileContainsError(t *testing.T) {
83+
dir := t.TempDir()
84+
w := newWriter(dir, time.Now())
85+
_ = w.Write(testPayload, 1, errors.New("something went wrong"))
86+
87+
entries, _ := os.ReadDir(dir)
88+
data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
89+
90+
var entry dlq.Entry
91+
_ = json.Unmarshal(data, &entry)
92+
93+
if entry.Error != "something went wrong" {
94+
t.Errorf("error field: want %q, got %q", "something went wrong", entry.Error)
95+
}
96+
}
97+
98+
func TestFileContainsAttemptCount(t *testing.T) {
99+
dir := t.TempDir()
100+
w := newWriter(dir, time.Now())
101+
_ = w.Write(testPayload, 7, errors.New("fail"))
102+
103+
entries, _ := os.ReadDir(dir)
104+
data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
105+
106+
var entry dlq.Entry
107+
_ = json.Unmarshal(data, &entry)
108+
109+
if entry.Attempts != 7 {
110+
t.Errorf("attempts: want 7, got %d", entry.Attempts)
111+
}
112+
}
113+
114+
func TestMultipleWritesCreateMultipleFiles(t *testing.T) {
115+
dir := t.TempDir()
116+
base := time.Now()
117+
118+
for i := 0; i < 5; i++ {
119+
p := model.RoutedPayload{
120+
Resource: model.FHIRResource{ID: fmt.Sprintf("msg-%d", i)},
121+
}
122+
w := newWriter(dir, base.Add(time.Duration(i)*time.Second))
123+
if err := w.Write(p, 1, errors.New("fail")); err != nil {
124+
t.Fatalf("write %d: %v", i, err)
125+
}
126+
}
127+
128+
entries, _ := os.ReadDir(dir)
129+
if len(entries) != 5 {
130+
t.Errorf("expected 5 files, got %d", len(entries))
131+
}
132+
}
133+
134+
func TestFileNameContainsMsgID(t *testing.T) {
135+
dir := t.TempDir()
136+
ts := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
137+
w := newWriter(dir, ts)
138+
p := model.RoutedPayload{Resource: model.FHIRResource{ID: "my-msg-123"}}
139+
_ = w.Write(p, 1, errors.New("fail"))
140+
141+
entries, _ := os.ReadDir(dir)
142+
if len(entries) != 1 {
143+
t.Fatalf("expected 1 file, got %d", len(entries))
144+
}
145+
name := entries[0].Name()
146+
if !strings.Contains(name, "my-msg-123") {
147+
t.Errorf("file name %q should contain msg ID", name)
148+
}
149+
if !strings.HasSuffix(name, ".json") {
150+
t.Errorf("file name %q should end with .json", name)
151+
}
152+
}
153+
154+
func TestTimestampInRFC3339(t *testing.T) {
155+
dir := t.TempDir()
156+
ts := time.Date(2024, 6, 20, 10, 30, 0, 0, time.UTC)
157+
w := newWriter(dir, ts)
158+
_ = w.Write(testPayload, 1, errors.New("fail"))
159+
160+
entries, _ := os.ReadDir(dir)
161+
data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
162+
163+
var entry dlq.Entry
164+
_ = json.Unmarshal(data, &entry)
165+
166+
if entry.Timestamp != "2024-06-20T10:30:00Z" {
167+
t.Errorf("timestamp: want 2024-06-20T10:30:00Z, got %s", entry.Timestamp)
168+
}
169+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Package retry wraps any Sender with exponential-backoff retry logic.
2+
//
3+
// The retryer calls the inner Sender up to MaxAttempts times. Between failures
4+
// it waits InitialBackoff * Multiplier^attempt before trying again. If the
5+
// context is cancelled during a wait the attempt loop stops immediately and
6+
// the context error is returned.
7+
package retry
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"time"
13+
14+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
15+
)
16+
17+
// Sender is the interface that the retryer wraps.
18+
type Sender interface {
19+
Send(ctx context.Context, payload model.RoutedPayload) error
20+
}
21+
22+
// Config controls retry behaviour.
23+
type Config struct {
24+
// MaxAttempts is the total number of send attempts (1 = no retry).
25+
MaxAttempts int
26+
27+
// InitialBackoff is the wait duration after the first failure.
28+
InitialBackoff time.Duration
29+
30+
// Multiplier scales the backoff after each subsequent failure.
31+
// 2.0 means: 1s → 2s → 4s → 8s …
32+
Multiplier float64
33+
}
34+
35+
// Retryer wraps a Sender and retries on failure with exponential backoff.
36+
type Retryer struct {
37+
inner Sender
38+
cfg Config
39+
// sleep is injectable for testing; defaults to sleepWithContext.
40+
sleep func(ctx context.Context, d time.Duration) error
41+
}
42+
43+
// New creates a Retryer with the given inner Sender and Config.
44+
// If MaxAttempts < 1 it is set to 1 (try once, no retry).
45+
// If Multiplier <= 0 it is set to 2.0.
46+
func New(inner Sender, cfg Config) *Retryer {
47+
if cfg.MaxAttempts < 1 {
48+
cfg.MaxAttempts = 1
49+
}
50+
if cfg.Multiplier <= 0 {
51+
cfg.Multiplier = 2.0
52+
}
53+
return &Retryer{
54+
inner: inner,
55+
cfg: cfg,
56+
sleep: sleepWithContext,
57+
}
58+
}
59+
60+
// SetSleep replaces the sleep implementation. It is used only in tests to
61+
// avoid real timer waits.
62+
func (r *Retryer) SetSleep(fn func(ctx context.Context, d time.Duration) error) {
63+
r.sleep = fn
64+
}
65+
66+
// Send calls the inner Sender, retrying up to MaxAttempts times on failure.
67+
// It returns the last error if all attempts are exhausted, or the context
68+
// error if the context is cancelled between retries.
69+
func (r *Retryer) Send(ctx context.Context, payload model.RoutedPayload) error {
70+
backoff := r.cfg.InitialBackoff
71+
var lastErr error
72+
73+
for attempt := 0; attempt < r.cfg.MaxAttempts; attempt++ {
74+
lastErr = r.inner.Send(ctx, payload)
75+
if lastErr == nil {
76+
return nil
77+
}
78+
79+
// Last attempt — do not sleep.
80+
if attempt == r.cfg.MaxAttempts-1 {
81+
break
82+
}
83+
84+
if err := r.sleep(ctx, backoff); err != nil {
85+
return fmt.Errorf("retry: context cancelled after %d attempt(s): %w", attempt+1, err)
86+
}
87+
backoff = time.Duration(float64(backoff) * r.cfg.Multiplier)
88+
}
89+
90+
return fmt.Errorf("retry: all %d attempt(s) failed: %w", r.cfg.MaxAttempts, lastErr)
91+
}
92+
93+
func sleepWithContext(ctx context.Context, d time.Duration) error {
94+
select {
95+
case <-time.After(d):
96+
return nil
97+
case <-ctx.Done():
98+
return ctx.Err()
99+
}
100+
}

0 commit comments

Comments
 (0)