set 10 min quotations timeout
This commit is contained in:
@@ -38,6 +38,8 @@ messaging:
|
||||
# Retain quote records after expiry to allow long-running payments to complete.
|
||||
quote_retention_hours: 72
|
||||
|
||||
max_fx_quote_ttl_ms: 600000
|
||||
|
||||
# Service endpoints are sourced from discovery; no static overrides.
|
||||
card_gateways:
|
||||
monetix:
|
||||
|
||||
@@ -38,6 +38,8 @@ messaging:
|
||||
# Retain quote records after expiry to allow long-running payments to complete.
|
||||
quote_retention_hours: 72
|
||||
|
||||
max_fx_quote_ttl_ms: 600000
|
||||
|
||||
# Service endpoints are sourced from discovery; no static overrides.
|
||||
card_gateways:
|
||||
monetix:
|
||||
|
||||
@@ -23,6 +23,7 @@ type config struct {
|
||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
|
||||
QuoteRetentionHrs int `yaml:"quote_retention_hours"`
|
||||
MaxFXQuoteTTLMs int64 `yaml:"max_fx_quote_ttl_ms"`
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
@@ -78,6 +79,11 @@ type limitsOverrideCfg struct {
|
||||
MaxOps int `yaml:"max_ops"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxFXQuoteTTL = 10 * time.Minute
|
||||
defaultMaxFXQuoteTTLMillis = int64(defaultMaxFXQuoteTTL / time.Millisecond)
|
||||
)
|
||||
|
||||
func (c clientConfig) callTimeout() time.Duration {
|
||||
if c.CallTimeoutSecs <= 0 {
|
||||
return 3 * time.Second
|
||||
@@ -92,6 +98,13 @@ func (c *config) quoteRetention() time.Duration {
|
||||
return time.Duration(c.QuoteRetentionHrs) * time.Hour
|
||||
}
|
||||
|
||||
func (c *config) maxFXQuoteTTLMillis() int64 {
|
||||
if c == nil || c.MaxFXQuoteTTLMs <= 0 {
|
||||
return defaultMaxFXQuoteTTLMillis
|
||||
}
|
||||
return c.MaxFXQuoteTTLMs
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,6 +55,7 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest
|
||||
if deps.quotationClient != nil {
|
||||
opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient))
|
||||
}
|
||||
opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis()))
|
||||
if deps.gatewayInvokeResolver != nil {
|
||||
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
|
||||
}
|
||||
|
||||
@@ -422,6 +422,15 @@ func WithClock(clock clockpkg.Clock) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxFXQuoteTTLMillis caps forwarded FX quote TTL requests.
|
||||
func WithMaxFXQuoteTTLMillis(value int64) Option {
|
||||
return func(s *Service) {
|
||||
if value > 0 {
|
||||
s.maxFXQuoteTTLMillis = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainResolver GatewayInvokeResolver, providerResolver GatewayInvokeResolver, logger mlogger.Logger) railGatewayDependency {
|
||||
result := railGatewayDependency{
|
||||
byID: map[string]rail.RailGateway{},
|
||||
|
||||
@@ -2,6 +2,7 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
@@ -23,6 +24,11 @@ func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxFXQuoteTTL = 10 * time.Minute
|
||||
defaultMaxFXQuoteTTLMillis = int64(defaultMaxFXQuoteTTL / time.Millisecond)
|
||||
)
|
||||
|
||||
var (
|
||||
errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised")
|
||||
errQuotationUnavailable = serviceError("payments.orchestrator: quotation service not configured")
|
||||
@@ -30,9 +36,10 @@ var (
|
||||
|
||||
// Service orchestrates payments across ledger, billing, FX, and chain domains.
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
clock clockpkg.Clock
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
clock clockpkg.Clock
|
||||
maxFXQuoteTTLMillis int64
|
||||
|
||||
deps serviceDependencies
|
||||
h handlerSet
|
||||
@@ -72,9 +79,10 @@ type componentSet struct {
|
||||
// NewService constructs a payment orchestrator service.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("payment_orchestrator"),
|
||||
storage: repo,
|
||||
clock: clockpkg.NewSystem(),
|
||||
logger: logger.Named("payment_orchestrator"),
|
||||
storage: repo,
|
||||
clock: clockpkg.NewSystem(),
|
||||
maxFXQuoteTTLMillis: defaultMaxFXQuoteTTLMillis,
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
@@ -88,6 +96,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
if svc.maxFXQuoteTTLMillis <= 0 {
|
||||
svc.maxFXQuoteTTLMillis = defaultMaxFXQuoteTTLMillis
|
||||
}
|
||||
|
||||
engine := defaultPaymentEngine{svc: svc}
|
||||
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
|
||||
|
||||
@@ -108,6 +108,23 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) clampFXIntentTTL(intent *sharedv1.PaymentIntent) *sharedv1.PaymentIntent {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
cloned, ok := proto.Clone(intent).(*sharedv1.PaymentIntent)
|
||||
if !ok || cloned == nil {
|
||||
return intent
|
||||
}
|
||||
if s == nil || s.maxFXQuoteTTLMillis <= 0 {
|
||||
return cloned
|
||||
}
|
||||
if fx := cloned.GetFx(); fx != nil && fx.GetTtlMs() > s.maxFXQuoteTTLMillis {
|
||||
fx.TtlMs = s.maxFXQuoteTTLMillis
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
@@ -149,10 +166,11 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
if in.Intent == nil {
|
||||
return nil, nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
intent := s.clampFXIntentTTL(in.Intent)
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
Intent: in.Intent,
|
||||
Intent: intent,
|
||||
PreviewOnly: false,
|
||||
}
|
||||
if !s.deps.quotation.available() {
|
||||
@@ -175,7 +193,7 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
OrgRef: in.OrgRef,
|
||||
OrgID: in.OrgID,
|
||||
Meta: in.Meta,
|
||||
Intent: in.Intent,
|
||||
Intent: intent,
|
||||
QuoteRef: ref,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
@@ -219,6 +220,83 @@ func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_ClampsForwardedFXTTL(t *testing.T) {
|
||||
const (
|
||||
requestedTTL = int64((15 * time.Minute) / time.Millisecond)
|
||||
maxTTL = int64((10 * time.Minute) / time.Millisecond)
|
||||
)
|
||||
|
||||
org := bson.NewObjectID()
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "EUR",
|
||||
Fx: &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
Firm: true,
|
||||
TtlMs: requestedTTL,
|
||||
},
|
||||
}
|
||||
intent = protoIntentFromModel(intentFromProto(intent))
|
||||
intent.Fx.TtlMs = requestedTTL
|
||||
|
||||
recordIntent := protoIntentFromModel(intentFromProto(intent))
|
||||
recordIntent.Fx.TtlMs = maxTTL
|
||||
|
||||
var capturedTTLMs int64
|
||||
svc := &Service{
|
||||
storage: stubRepo{
|
||||
quotes: &helperQuotesStore{
|
||||
records: map[string]*model.PaymentQuoteRecord{
|
||||
"q1": {
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(recordIntent),
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clock: clockpkg.NewSystem(),
|
||||
maxFXQuoteTTLMillis: maxTTL,
|
||||
deps: serviceDependencies{
|
||||
quotation: quotationDependency{
|
||||
client: &helperQuotationClient{
|
||||
quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
||||
capturedTTLMs = req.GetIntent().GetFx().GetTtlMs()
|
||||
return "ationv1.QuotePaymentResponse{
|
||||
Quote: &sharedv1.PaymentQuote{
|
||||
QuoteRef: "q1",
|
||||
},
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
Intent: intent,
|
||||
IdempotencyKey: "idem-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if capturedTTLMs != maxTTL {
|
||||
t.Fatalf("expected forwarded ttl_ms to be clamped to %d, got %d", maxTTL, capturedTTLMs)
|
||||
}
|
||||
if intent.GetFx().GetTtlMs() != requestedTTL {
|
||||
t.Fatalf("expected original intent ttl to stay unchanged, got %d", intent.GetFx().GetTtlMs())
|
||||
}
|
||||
if resolvedIntent == nil || resolvedIntent.GetFx().GetTtlMs() != maxTTL {
|
||||
t.Fatalf("expected resolved intent ttl to match stored clamped value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := bson.NewObjectID()
|
||||
|
||||
@@ -21,8 +21,8 @@ func (e serviceError) Error() string {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultFeeQuoteTTLMillis int64 = 120000
|
||||
defaultOracleTTLMillis int64 = 60000
|
||||
defaultFeeQuoteTTLMillis int64 = 600000
|
||||
defaultOracleTTLMillis int64 = 600000
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
Reference in New Issue
Block a user