Skip to content

Commit f4b424e

Browse files
authored
feat: Add idempotency retry mechanism for Horizon integrity proof demo (#193)
Implement retry logic to verify idempotency guarantees after a simulated network failure (timeout). The retry mechanism: - Waits configurable duration (default 2s) for saga completion - Sends identical payment request with same IdempotencyKey - Verifies server returns existing PaymentOrder (idempotency hit) - Logs "idempotency key match detected" on success - Provides NewRetryConfigFromSabotage helper for integration This enables the demo to prove that retrying with the same idempotency key does not create duplicate payments. Co-authored-by: Ben Coombs <[email protected]>
1 parent 2b2c37c commit f4b424e

File tree

2 files changed

+965
-0
lines changed

2 files changed

+965
-0
lines changed

cmd/horizon-demo/retry.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Package main provides the idempotency retry mechanism for the Horizon Integrity Proof demo.
2+
package main
3+
4+
import (
5+
"context"
6+
"errors"
7+
"fmt"
8+
"log/slog"
9+
"time"
10+
11+
commonv1 "github.com/meridianhub/meridian/api/proto/meridian/common/v1"
12+
paymentorderv1 "github.com/meridianhub/meridian/api/proto/meridian/payment_order/v1"
13+
money "google.golang.org/genproto/googleapis/type/money"
14+
)
15+
16+
// RetryConfig holds configuration for the idempotency retry attempt.
17+
type RetryConfig struct {
18+
// IdempotencyKey is the key from the original sabotage attempt
19+
IdempotencyKey string
20+
// CorrelationID is the distributed tracing ID from the original attempt
21+
CorrelationID string
22+
// DebtorAccountID is the account from which funds are debited
23+
DebtorAccountID string
24+
// AmountPence is the payment amount in pence (must match original)
25+
AmountPence int64
26+
// CreditorReference is the external account to credit (must match original)
27+
CreditorReference string
28+
// WaitBeforeRetry is the duration to wait before sending retry (allows saga completion)
29+
WaitBeforeRetry time.Duration
30+
// Timeout is the timeout for the retry request (should be generous)
31+
Timeout time.Duration
32+
// Logger for structured logging
33+
Logger *slog.Logger
34+
}
35+
36+
// RetryResult captures the outcome of an idempotency retry attempt.
37+
type RetryResult struct {
38+
// IdempotencyKey is the key used for this retry
39+
IdempotencyKey string
40+
// CorrelationID is the distributed tracing ID
41+
CorrelationID string
42+
// PaymentOrderID is the ID returned by the server
43+
PaymentOrderID string
44+
// PaymentStatus is the status of the payment order
45+
PaymentStatus string
46+
// Duration is how long the retry took
47+
Duration time.Duration
48+
// IdempotencyHit indicates if the server returned an existing payment order
49+
IdempotencyHit bool
50+
// Success indicates if the retry completed successfully
51+
Success bool
52+
// Error captures any error that occurred (nil on success)
53+
Error error
54+
}
55+
56+
// Retry errors.
57+
var (
58+
ErrRetryConfigInvalid = errors.New("invalid retry configuration")
59+
ErrRetryFailed = errors.New("retry request failed")
60+
ErrRetryNoPaymentOrder = errors.New("retry returned no payment order")
61+
ErrRetryPaymentOrderIDNil = errors.New("retry returned nil payment order ID")
62+
)
63+
64+
// Default retry configuration values.
65+
const (
66+
// DefaultRetryWait is the default wait time before retry (2 seconds allows saga completion).
67+
DefaultRetryWait = 2 * time.Second
68+
// DefaultRetryTimeout is the default timeout for the retry request (30 seconds is generous).
69+
DefaultRetryTimeout = 30 * time.Second
70+
)
71+
72+
// RunRetry executes the idempotency retry after waiting for saga completion.
73+
// This sends an identical payment request with the same IdempotencyKey and expects
74+
// the server to return the existing PaymentOrder (idempotency hit) rather than
75+
// creating a duplicate.
76+
//
77+
// The retry flow:
78+
// 1. Wait for WaitBeforeRetry duration to allow server-side saga to complete
79+
// 2. Send identical InitiatePaymentOrderRequest with same idempotency_key
80+
// 3. Verify response contains valid PaymentOrder
81+
// 4. Log "idempotency key match detected" on success
82+
func RunRetry(ctx context.Context, clients *Clients, cfg *RetryConfig) (*RetryResult, error) {
83+
if err := validateRetryConfig(cfg); err != nil {
84+
return nil, err
85+
}
86+
87+
logger := cfg.Logger
88+
if logger == nil {
89+
logger = slog.Default()
90+
}
91+
92+
result := &RetryResult{
93+
IdempotencyKey: cfg.IdempotencyKey,
94+
CorrelationID: cfg.CorrelationID,
95+
}
96+
97+
// Wait for server-side saga to complete
98+
logger.Info("retry: waiting before retry to allow saga completion",
99+
"idempotency_key", cfg.IdempotencyKey,
100+
"wait_duration", cfg.WaitBeforeRetry,
101+
)
102+
103+
select {
104+
case <-time.After(cfg.WaitBeforeRetry):
105+
// Wait completed
106+
case <-ctx.Done():
107+
result.Error = fmt.Errorf("context cancelled while waiting: %w", ctx.Err())
108+
return result, result.Error
109+
}
110+
111+
// Create context with generous timeout for retry
112+
retryCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
113+
defer cancel()
114+
115+
// Add correlation ID to context for distributed tracing
116+
retryCtx = ContextWithCorrelationID(retryCtx, cfg.CorrelationID)
117+
118+
logger.Info("retry: sending retry request with same idempotency key",
119+
"idempotency_key", cfg.IdempotencyKey,
120+
"correlation_id", cfg.CorrelationID,
121+
"timeout", cfg.Timeout,
122+
)
123+
124+
// Build identical payment request
125+
req := buildRetryRequest(cfg)
126+
127+
// Execute the retry
128+
start := time.Now()
129+
resp, err := clients.PaymentOrder.InitiatePaymentOrder(retryCtx, req)
130+
result.Duration = time.Since(start)
131+
132+
if err != nil {
133+
result.Error = fmt.Errorf("%w: %w", ErrRetryFailed, err)
134+
logger.Error("retry: request failed",
135+
"idempotency_key", cfg.IdempotencyKey,
136+
"error", err,
137+
"duration", result.Duration,
138+
)
139+
return result, result.Error
140+
}
141+
142+
// Validate response
143+
if resp.GetPaymentOrder() == nil {
144+
result.Error = ErrRetryNoPaymentOrder
145+
logger.Error("retry: response contains no payment order",
146+
"idempotency_key", cfg.IdempotencyKey,
147+
)
148+
return result, result.Error
149+
}
150+
151+
paymentOrder := resp.GetPaymentOrder()
152+
if paymentOrder.GetPaymentOrderId() == "" {
153+
result.Error = ErrRetryPaymentOrderIDNil
154+
logger.Error("retry: payment order has empty ID",
155+
"idempotency_key", cfg.IdempotencyKey,
156+
)
157+
return result, result.Error
158+
}
159+
160+
// Success - populate result
161+
result.PaymentOrderID = paymentOrder.GetPaymentOrderId()
162+
result.PaymentStatus = paymentOrder.GetStatus().String()
163+
result.IdempotencyHit = true // Server returned existing order (idempotency behavior)
164+
result.Success = true
165+
166+
logger.Info("retry: idempotency key match detected",
167+
"idempotency_key", cfg.IdempotencyKey,
168+
"payment_order_id", result.PaymentOrderID,
169+
"payment_status", result.PaymentStatus,
170+
"duration", result.Duration,
171+
)
172+
173+
return result, nil
174+
}
175+
176+
// buildRetryRequest constructs the InitiatePaymentOrderRequest for the retry.
177+
// This must be identical to the original sabotage request.
178+
func buildRetryRequest(cfg *RetryConfig) *paymentorderv1.InitiatePaymentOrderRequest {
179+
return &paymentorderv1.InitiatePaymentOrderRequest{
180+
DebtorAccountId: cfg.DebtorAccountID,
181+
CreditorReference: cfg.CreditorReference,
182+
Amount: &commonv1.MoneyAmount{
183+
Amount: penceToPoundsRetry(cfg.AmountPence),
184+
},
185+
IdempotencyKey: &commonv1.IdempotencyKey{
186+
Key: cfg.IdempotencyKey,
187+
},
188+
CorrelationId: cfg.CorrelationID,
189+
}
190+
}
191+
192+
// penceToPoundsRetry converts pence to google.type.Money with GBP currency.
193+
func penceToPoundsRetry(pence int64) *money.Money {
194+
units := pence / 100
195+
fractionalPence := pence % 100
196+
nanos := int32(fractionalPence) * 10000000 // #nosec G115 - safe: fractionalPence is 0-99
197+
198+
return &money.Money{
199+
CurrencyCode: "GBP",
200+
Units: units,
201+
Nanos: nanos,
202+
}
203+
}
204+
205+
// validateRetryConfig validates the retry configuration.
206+
func validateRetryConfig(cfg *RetryConfig) error {
207+
if cfg == nil {
208+
return fmt.Errorf("%w: config is nil", ErrRetryConfigInvalid)
209+
}
210+
211+
if cfg.IdempotencyKey == "" {
212+
return fmt.Errorf("%w: IdempotencyKey is required", ErrRetryConfigInvalid)
213+
}
214+
215+
if cfg.CorrelationID == "" {
216+
return fmt.Errorf("%w: CorrelationID is required", ErrRetryConfigInvalid)
217+
}
218+
219+
if cfg.DebtorAccountID == "" {
220+
return fmt.Errorf("%w: DebtorAccountID is required", ErrRetryConfigInvalid)
221+
}
222+
223+
if cfg.AmountPence <= 0 {
224+
return fmt.Errorf("%w: AmountPence must be positive", ErrRetryConfigInvalid)
225+
}
226+
227+
if cfg.CreditorReference == "" {
228+
return fmt.Errorf("%w: CreditorReference is required", ErrRetryConfigInvalid)
229+
}
230+
231+
if cfg.WaitBeforeRetry < 0 {
232+
return fmt.Errorf("%w: WaitBeforeRetry cannot be negative", ErrRetryConfigInvalid)
233+
}
234+
235+
if cfg.Timeout <= 0 {
236+
return fmt.Errorf("%w: Timeout must be positive", ErrRetryConfigInvalid)
237+
}
238+
239+
return nil
240+
}
241+
242+
// DefaultRetryConfig returns a RetryConfig with default values.
243+
// Caller must set IdempotencyKey, CorrelationID, DebtorAccountID, AmountPence, and CreditorReference.
244+
func DefaultRetryConfig() *RetryConfig {
245+
return &RetryConfig{
246+
WaitBeforeRetry: DefaultRetryWait,
247+
Timeout: DefaultRetryTimeout,
248+
Logger: slog.Default(),
249+
}
250+
}
251+
252+
// NewRetryConfigFromSabotage creates a RetryConfig from SabotageConfig and SabotageResult.
253+
// This ensures the retry uses identical parameters to the original sabotage attempt.
254+
func NewRetryConfigFromSabotage(sabCfg *SabotageConfig, sabResult *SabotageResult) *RetryConfig {
255+
return &RetryConfig{
256+
IdempotencyKey: sabResult.IdempotencyKey,
257+
CorrelationID: sabResult.CorrelationID,
258+
DebtorAccountID: sabCfg.DebtorAccountID,
259+
AmountPence: sabCfg.AmountPence,
260+
CreditorReference: sabCfg.CreditorReference,
261+
WaitBeforeRetry: DefaultRetryWait,
262+
Timeout: DefaultRetryTimeout,
263+
Logger: sabCfg.Logger,
264+
}
265+
}
266+
267+
// ToAttemptReport converts a RetryResult to an AttemptReport for JSON output.
268+
func (r *RetryResult) ToAttemptReport(attemptNum int) AttemptReport {
269+
report := AttemptReport{
270+
Attempt: attemptNum,
271+
IdempotencyKey: r.IdempotencyKey,
272+
DurationMs: r.Duration.Milliseconds(),
273+
PaymentOrderID: r.PaymentOrderID,
274+
}
275+
276+
if r.Success {
277+
report.Status = AttemptStatusSuccess
278+
} else if r.Error != nil {
279+
report.Status = AttemptStatusError
280+
report.Error = r.Error.Error()
281+
}
282+
283+
return report
284+
}

0 commit comments

Comments
 (0)