Repository navigation
Expand file tree
/
Copy pathclient.go
More file actions
2920 lines (2717 loc) · 120 KB
/
Copy pathclient.go
File metadata and controls
2920 lines (2717 loc) · 120 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package gosdk
import (
"cmp"
"context"
"errors"
"fmt"
"net"
goruntime "runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/oddin-gg/gosdk/internal/api"
"github.com/oddin-gg/gosdk/internal/cache"
"github.com/oddin-gg/gosdk/internal/config"
"github.com/oddin-gg/gosdk/internal/factory"
"github.com/oddin-gg/gosdk/internal/feed"
log "github.com/oddin-gg/gosdk/internal/log"
"github.com/oddin-gg/gosdk/internal/market"
"github.com/oddin-gg/gosdk/internal/producer"
"github.com/oddin-gg/gosdk/internal/recovery"
"github.com/oddin-gg/gosdk/internal/replay"
"github.com/oddin-gg/gosdk/internal/sport"
"github.com/oddin-gg/gosdk/internal/whoami"
"github.com/oddin-gg/gosdk/types"
)
// clientMode is the internal lifecycle phase. The public ConnectionState
// returned from ConnectionState() is *derived* from this — kept in
// connectState (atomic.Int32) for lock-free reads, but mode is the
// authoritative value, written only under lifecycleMu.
//
// The mode answers cross-flow questions in one place that used to be
// spread across connectState + connecting + rmq.opened + subsClosed:
// - Is the client closed? mode in {modeClosing, modeClosed}
// - Is AMQP open only for replay? mode == modeBrokerOnly
// - Is the full feed pipeline ready? mode == modeNormalReady
// - Can a subscription be admitted? mode not in {modeClosing, modeClosed}
//
// Transition graph (each edge taken under lifecycleMu):
//
// modeNew ──ensureBroker──▶ modeBrokerOnly
// modeNew ──ensureNormal──▶ modeNormalConnecting ──success──▶ modeNormalReady
// modeBrokerOnly ──ensureNormal──▶ modeNormalConnecting ──success──▶ modeNormalReady
// modeNormalConnecting ──failure──▶ modeNew or modeBrokerOnly (whichever it came from)
// {modeNew, modeBrokerOnly, modeNormalConnecting, modeNormalReady} ──beginClose──▶ modeClosing ──runShutdown done──▶ modeClosed
type clientMode uint8
const (
modeNew clientMode = iota // initial; nothing open
modeBrokerOnly // replay opened AMQP only; full pipeline not built
modeNormalConnecting // ensureNormal in flight
modeNormalReady // full pipeline ready (producers + recovery + alive session)
modeClosing // Close called; runShutdown started
modeClosed // runShutdown finished
)
// publicState maps a clientMode to its public ConnectionState observable.
// modeBrokerOnly maps to NotConnected because replay subscriptions
// deliberately do NOT advertise a Connected client (the full feed-SDK
// pipeline isn't initialised — only AMQP is open).
func (m clientMode) publicState() ConnectionState {
switch m {
case modeNew, modeBrokerOnly:
return ConnectionStateNotConnected
case modeNormalConnecting:
return ConnectionStateConnecting
case modeNormalReady:
return ConnectionStateConnected
case modeClosing:
// Distinct from Closed: events.go documents Closed as "Close has
// COMPLETED" (channels closed, subscriptions terminated). During
// teardown those are still live, so publishing Closed here let a
// poller observe "closed" while Done()s hadn't fired yet.
return ConnectionStateClosing
case modeClosed:
return ConnectionStateClosed
}
return ConnectionStateNotConnected
}
// runtime is a generation-paired snapshot of the connection-layer
// pointers. ensureBroker / ensureNormal capture this under lifecycleMu
// and return it so callers (Subscribe, Connect's success path) operate
// on a coherent (rmq, rmgr) pair across the lifetime of the call. Without
// this, a concurrent Connect rollback that calls resetConnectionLayer
// between two atomic.Pointer.Load()s could otherwise leave Subscribe
// opening the old rmq while building a session against the new rmgr.
//
// The atomic.Pointer fields on Client remain — public Recover* and
// ProducerStatus methods load them directly without lifecycleMu, since
// they only need the latest, not a generation-paired pair.
type runtime struct {
rmq *feed.Client
rmgr *recovery.Manager
}
// connectResult is one ensureNormal attempt's outcome record — see the
// connectAttempt field. Fields are written exactly once, before
// close(done), and are immutable afterwards.
type connectResult struct {
done chan struct{}
err error
rt runtime
ok bool // attempt reached modeNormalReady
}
// Per-channel buffer sizes for the lossy event streams. Sized per
// NEXT.md §0.3 / §9.3 — RecoveryEvents largest because completion
// notifications matter most; APIEvents at the documented 256;
// ConnectionEvents tiny because transitions are rare.
// Each channel uses drop-oldest semantics on overflow.
const (
connEventBuffer = 32 // NEXT.md §0.3 — transitions are rare
recoveryEventBuffer = 1024 // NEXT.md §0.3 — largest; recovery completes matter most
apiEventBuffer = 256 // NEXT.md §9.3 — default 256
// maxSubscriptionBuffer caps WithSubscriptionBuffer — see
// validateConfigBounds for the rationale (post-broker-setup
// allocation must never panic/OOM on an accepted config).
maxSubscriptionBuffer = 1 << 20
)
// dropWarnInterval rate-limits the slog.Warn issued when an event channel
// drops. One warning per channel per interval avoids log spam under
// sustained overflow.
const dropWarnInterval = 5 * time.Second
// snapshotKeyTemplate is the routing-key prefix for snapshot-complete
// messages. The trailing field is the SDK node id (or "-" when unset).
const snapshotKeyTemplate = "-.-.-.snapshot_complete.-.-.-."
// Client is the flat v1.0.0 SDK entry-point. It replaces the legacy
// OddsFeed + manager-of-managers shape with direct methods.
//
// Lifecycle (NEXT.md §0.1, §8):
// - New(ctx, cfg) does API + cache + producer setup. It does NOT open AMQP.
// - Connect(ctx) opens AMQP and starts the recovery loop. Optional —
// Subscribe lazy-connects on first call.
// - Subscribe(ctx, opts...) returns a *Subscription pumping messages.
// - Close(ctx) terminates everything; idempotent. It is ABRUPT for
// still-active subscriptions (they are aborted, not drained) — call
// Subscription.Close(ctx) on each FIRST for a graceful drain. ctx
// caps the wait, not the shutdown work.
//
// Concurrency: all methods are safe for concurrent use after New returns.
type Client struct {
cfg Config
cfgAdpt config.Config
logger *log.Logger
apiClient *api.Client
whoAmIManager whoAmIManager
producerManager *producer.Manager
cacheManager *cache.Manager
feedMessageFactory *factory.FeedMessageFactory
// recoveryManager and rabbitMQClient are *replaced* on Connect's
// rollback paths (see resetConnectionLayer). Reads happen on every
// public Recover* / ProducerStatus / Subscribe call and must
// observe a consistent pointer concurrent with the writes — so
// they're stored as atomic.Pointer rather than plain fields.
recoveryManager atomic.Pointer[recovery.Manager]
rabbitMQClient atomic.Pointer[feed.Client]
marketDescriptionManager marketDescriptionManager
sportsInfoManager sportsInfoManager
replayManager replayManager
replay *Replay
// lifecycleMu serialises every state-machine transition: mode
// changes, connect-in-flight bookkeeping, and subs admission.
// connectState is its public-observable mirror — read lock-free
// from ConnectionState() but written only under lifecycleMu so
// readers see a consistent edge with mode.
//
// Per NEXT.md §0: "retryable Connect, not sync.Once" — a failed
// first attempt returns mode to its prior value (modeNew or
// modeBrokerOnly) so the next call retries from scratch.
lifecycleMu sync.Mutex
mode clientMode // source of truth (under lifecycleMu)
connectState atomic.Int32 // ConnectionState; lock-free mirror of mode for public reads
connecting bool // a normal-pipeline attempt is in flight
connectDone chan struct{} // closed when the in-flight attempt finishes
// connectAttempt is the IMMUTABLE result record of the attempt
// connectDone belongs to: the owner writes err/rt/ok exactly once
// before closing done; waiters read the record they captured under
// lifecycleMu with no re-lock after waking. Pre-fix waiters re-read
// the Client's mutable connectErr/mode/runtime after <-done — a
// fresh attempt starting in that window (clearing connectErr,
// replacing connectDone) made them observe the LATER attempt's
// state: a generic error, or even the new attempt's success,
// instead of their own attempt's outcome (losing errors.Is
// classification of the real failure).
connectAttempt *connectResult
aliveSession sdkOddsFeedSession
internalCancel context.CancelFunc
// brokerOpenWG tracks in-flight ensureBroker calls that have
// passed mode-admission (i.e., are about to call rt.rmq.Open).
// Add happens under lifecycleMu while mode is not modeClosing /
// modeClosed, so beginClose's mode transition serialises against
// it: after beginClose, no new Add can occur (ensureBroker
// returns ErrAlreadyClosed). runShutdown waits on this WG
// before calling rmq.Close, so an in-flight replay broker open
// never races with shutdown's broker teardown.
brokerOpenWG sync.WaitGroup
// sessionSetupWG tracks in-flight Subscribe session setups (the
// window from session.Open to admitSubscription/rollback). Add
// happens under lifecycleMu with mode-admission — after beginClose
// no new setup can start — and runShutdown waits on it before
// snapshotting c.subs, so a pre-admission session can never escape
// shutdown's join.
sessionSetupWG sync.WaitGroup
// connectedEmitted gates ConnectionConnected to at-most-once per
// "up edge" — the window from any non-connected state into Connected,
// until the next Disconnected/Reconnecting event clears it. Both
// onFeedEvent and Connect's success path route their Connected emits
// through emitConnConnectedOnce so consumers observe exactly one
// ConnectionConnected per transition, regardless of whether the
// broker dial happened during this Connect (feed-layer fires the
// event) or during a prior replay subscription (rmq.Open is a no-op
// during this Connect). Cleared on Disconnect/Reconnecting so the
// next reconnect's Connected emits naturally; cleared at the top of
// each Connect attempt so a rolled-back attempt's stale "true" can't
// mute a fresh attempt's emit.
connectedEmitted atomic.Bool
// feedDown tracks feed-layer broker liveness: set on
// EventDisconnected/EventReconnecting, cleared on EventConnected.
// A broker drop while the pipeline is up does NOT change mode (the
// client is still lifecycle-Connected; the feed layer reconnects
// forever underneath) — but the ConnectionState() polling contract
// promises reconnect visibility ("Connecting = a dial or reconnect
// attempt is in flight"). ConnectionState() overlays this flag onto
// the mode-derived state so a consumer that missed the lossy
// Reconnecting event still observes the reconnect window by polling.
// Maintained unconditionally in onFeedEvent (even in replay-only
// mode, where it stays invisible) so a later ensureNormal that
// reuses the already-open rmq inherits an accurate value.
feedDown atomic.Bool
// subscriptions tracked for shutdown propagation.
subsMu sync.Mutex
subs map[uuid.UUID]*Subscription
// Lossy event channels (NEXT.md §19.3).
connEvents chan ConnectionEvent
recvEvents chan RecoveryEvent
apiEvents chan APIEvent
// eventsMu + eventsClosed gate ALL sends on the three event
// channels. Senders take RLock + check the flag; runShutdown
// takes Lock + sets the flag + closes the channels. Without
// this, an in-flight emitter that already snapshotted the
// `Emit` callback (under the api.Client's RLock) could send on
// a channel runShutdown just closed — send-on-closed-channel panic.
eventsMu sync.RWMutex
eventsClosed bool
// Shutdown state.
closeOnce sync.Once
closed chan struct{}
closeErr error
wg sync.WaitGroup
// lifetimeCtx is the construction-to-Close lifetime of this client:
// the detach root handed to the who-am-i manager (and, via
// cache.NewManager, the root the cache layer derives its own
// lifetime from). Cancelled by teardownPartialInit when New fails
// and by runShutdown on Close, so detached loads started during a
// failed construction (preload catalog fetches, the who-am-i probe)
// are cancelled instead of running on for up to LoadTimeout against
// a client that will never exist.
lifetimeCtx context.Context
lifetimeCancel context.CancelFunc
// Per-channel last-warning timestamp for rate-limiting drop slog.Warn.
// Stored as UnixNano to allow lock-free CompareAndSwap-based gating.
lastDropWarnConn atomic.Int64
lastDropWarnRecv atomic.Int64
lastDropWarnAPI atomic.Int64
}
// RecoveryEventKind discriminates the populated payload on a
// RecoveryEvent. types.ProducerStatus and the event-recovery payload
// are both INTERFACES (nilable) — Kind is the documented
// discriminator; switch on it rather than nil-probing the fields.
type RecoveryEventKind int
const (
// RecoveryEventKindUnknown is the zero value — only used as a
// guard when reading an uninitialized RecoveryEvent. Never
// emitted by the SDK.
RecoveryEventKindUnknown RecoveryEventKind = iota
// RecoveryEventKindProducerStatus marks events carrying a
// ProducerStatus payload.
RecoveryEventKindProducerStatus
// RecoveryEventKindEventRecovery marks events carrying an
// EventRecoveryMessage payload (per-event recovery completion).
RecoveryEventKindEventRecovery
)
// String renders the kind for logs/debugging.
func (k RecoveryEventKind) String() string {
switch k {
case RecoveryEventKindProducerStatus:
return "ProducerStatus"
case RecoveryEventKindEventRecovery:
return "EventRecovery"
default:
return "Unknown"
}
}
// RecoveryEvent is the typed event delivered on RecoveryEvents().
//
// Exactly one of ProducerStatus or EventRecovery is populated; Kind
// names which. Consumers should switch on Kind rather than nil-probe
// the fields — BOTH payloads are interfaces and therefore nilable.
type RecoveryEvent struct {
Kind RecoveryEventKind
ProducerStatus types.ProducerStatus
EventRecovery types.EventRecoveryMessage
At time.Time
}
// New constructs a Client. It does NOT open AMQP — call Connect or
// Subscribe to do that. The bookmaker-details API call is made eagerly
// so configuration errors surface up-front; pass a ctx with a timeout if
// you want to bound that probe.
func New(ctx context.Context, cfg Config) (*Client, error) {
if err := validateConfig(&cfg); err != nil {
return nil, err
}
c := &Client{
cfg: cfg,
cfgAdpt: newConfigAdapter(&cfg),
subs: make(map[uuid.UUID]*Subscription),
connEvents: make(chan ConnectionEvent, connEventBuffer),
recvEvents: make(chan RecoveryEvent, recoveryEventBuffer),
apiEvents: make(chan APIEvent, apiEventBuffer),
closed: make(chan struct{}),
}
c.mode = modeNew
c.connectState.Store(int32(ConnectionStateNotConnected))
// Construction-lifetime ctx (see field doc): survives the caller's
// New deadline but dies with teardownPartialInit / Close.
c.lifetimeCtx, c.lifetimeCancel = context.WithCancel(context.WithoutCancel(ctx))
c.apiClient = api.NewWithLogger(c.cfgAdpt, cfg.Logger(), c.cfg.httpClientTimeout)
if h := cfg.HTTPClient(); h != nil {
c.apiClient.SetHTTPClient(h)
}
c.installAPICapture()
// The configured logger must reach the probe path too — the
// token-expiry warning fires from here, and the nil-logger
// constructor would route it to slog.Default() instead of the
// WithLogger-supplied sink.
c.whoAmIManager = whoami.NewManagerWithLogger(c.lifetimeCtx, c.cfgAdpt, c.apiClient, cfg.Logger()) //nolint:contextcheck // lifetimeCtx is derived from ctx above; it is the construction-to-Close cancellation root
details, err := c.whoAmIManager.BookmakerDetails(ctx)
if err != nil {
c.teardownPartialInit(ctx)
return nil, fmt.Errorf("gosdk: who-am-i probe: %w", err)
}
logger := cfg.Logger()
c.logger = log.New(logger).WithField("client_id", details.BookmakerID())
c.producerManager = producer.NewManager(c.cfgAdpt, c.apiClient, c.logger)
// The cache manager derives its own lifetime from the client's, so
// both teardownPartialInit (via lifetimeCancel) and Manager.Close
// cancel in-flight detached loads.
c.cacheManager = cache.NewManager(c.lifetimeCtx, c.apiClient, c.cfgAdpt, c.logger, c.cfg.preloadLocales) //nolint:contextcheck // lifetimeCtx is derived from ctx above; it is the construction-to-Close cancellation root
entityFactory := factory.NewEntityFactory(c.cacheManager)
marketDescriptionFactory := factory.NewMarketDescriptionFactory(
c.cacheManager.MarketDescriptionCache,
c.cacheManager.MarketVoidReasonsCache,
c.cacheManager.PlayersCache,
c.cacheManager.CompetitorCache,
)
marketDataFactory := factory.NewMarketDataFactory(c.cfgAdpt, marketDescriptionFactory)
// MarketFactory's locales drive AMQP message-build name resolution.
// Default locale is always first (the primary), then any preload
// locales follow — both are filled in the cache on first build.
marketFactoryLocales := []types.Locale{c.cfg.DefaultLocale()}
for _, l := range c.cfg.preloadLocales {
if l == c.cfg.DefaultLocale() {
continue
}
marketFactoryLocales = append(marketFactoryLocales, l)
}
marketFactory := factory.NewMarketFactory(
marketDataFactory,
marketFactoryLocales,
c.cfg.messageNameResolution,
c.logger,
)
if !c.cfg.messageNameResolution {
// Market.Name / Outcome.Name will report None for every locale
// on every message — the same shape a catalog miss produces, so
// leave a findable trace of the deliberate cause.
c.logger.Info("gosdk: market/outcome name resolution disabled by WithMessageNameResolution(false); Market.Name and Outcome.Name report None on every message")
}
c.feedMessageFactory = factory.NewFeedMessageFactory(
entityFactory,
marketFactory,
c.producerManager,
c.cfgAdpt,
c.logger,
)
c.marketDescriptionManager = market.NewManager(c.cacheManager, marketDescriptionFactory, c.cfgAdpt)
c.sportsInfoManager = sport.NewManager(entityFactory, c.apiClient, c.cacheManager, c.cfgAdpt)
c.replayManager = replay.NewManager(c.apiClient, c.cfgAdpt, c.sportsInfoManager)
c.replay = &Replay{client: c}
// Eager static-catalog warm (NEXT.md §8 step 6): WithPreloadLocales
// promises the sports + market-description catalogs are fetched
// up-front for every listed locale, moving that HTTP latency out of
// first-message processing. Warmed at the CACHE layer on purpose —
// the manager-level Sports() would fan out one tournament fetch per
// sport via BuildSport, turning a two-request warm into ~50 per
// locale. A warm failure FAILS New (§8: "If any step fails, partial
// state is torn down and an error is returned") — the same
// fail-fast contract as the who-am-i probe, and the only behaviour
// under which the preload promise is actually kept: warning and
// continuing would silently move the fetch (and its failure mode)
// back onto first-message processing. Bounded by the caller's ctx.
if len(c.cfg.preloadLocales) > 0 {
if _, err := c.cacheManager.SportDataCache.Sports(ctx, marketFactoryLocales); err != nil {
c.teardownPartialInit(ctx)
return nil, fmt.Errorf("gosdk: preload sports catalog (locales=%v): %w", marketFactoryLocales, err)
}
if _, err := c.cacheManager.MarketDescriptionCache.MultiLocalizedMarketDescriptions(ctx, marketFactoryLocales); err != nil {
c.teardownPartialInit(ctx)
return nil, fmt.Errorf("gosdk: preload market descriptions (locales=%v): %w", marketFactoryLocales, err)
}
}
// Connection-layer components (rabbitMQClient, recoveryManager) are
// one-shot in their internals — Open/Close transitions can't be
// undone. We construct fresh instances here for the first attempt;
// any failed Connect rollback path calls resetConnectionLayer to
// re-construct them so a retry sees a clean state.
c.resetConnectionLayer()
return c, nil
}
// teardownPartialInit releases whatever New allocated before a failed
// construction step (NEXT.md §8: "If any step fails, partial state is
// torn down and an error is returned"). Nil-checks every component so
// it is safe from any point in the construction sequence. ctx is New's
// caller ctx — its VALUES flow through, but its cancellation is severed
// (WithoutCancel): the teardown must run even when that ctx expiring is
// exactly why we're here.
func (c *Client) teardownPartialInit(ctx context.Context) {
// Cancel the construction lifetime FIRST: any detached load still
// in flight (who-am-i probe, preload catalog fetch) aborts its
// HTTP request now instead of running up to LoadTimeout against a
// client New will never return.
if c.lifetimeCancel != nil {
c.lifetimeCancel()
}
if c.cacheManager != nil {
// Bounded, Background-rooted: construction failed, the caller's
// ctx may already be dead, and teardown must still run.
tearCtx, tearCancel := context.WithTimeout(context.Background(), c.cfg.shutdownTimeout)
c.cacheManager.CloseCtx(tearCtx) //nolint:contextcheck // intentional Background root (see above)
tearCancel()
}
if c.apiClient != nil {
// Bounded join: even during construction teardown, a custom
// HTTP transport that ignores ctx cancellation must not hang
// New's error return past the configured shutdown budget.
closeCtx, closeCancel := context.WithTimeout(context.WithoutCancel(ctx), c.cfg.shutdownTimeout)
defer closeCancel()
c.apiClient.CloseCtx(closeCtx)
}
}
// validateConfig rejects configurations that cannot possibly work,
// wrapping ErrInvalidConfig so callers can errors.Is (NEXT.md §12:
// validation happens in New, not NewConfig). Endpoint resolution is
// checked up-front so a bad environment/region combination surfaces as
// a config error instead of a confusing who-am-i probe failure.
func validateConfig(cfg *Config) error {
if cfg.accessToken == "" {
return fmt.Errorf("%w: access token is required", ErrInvalidConfig)
}
if err := validateRegion(cfg.selectedRegion); err != nil {
return err
}
adpt := newConfigAdapter(cfg)
if _, err := adpt.APIURL(); err != nil {
return fmt.Errorf("%w: resolve api endpoint: %w", ErrInvalidConfig, err)
}
if _, err := adpt.MQURL(); err != nil {
return fmt.Errorf("%w: resolve mq endpoint: %w", ErrInvalidConfig, err)
}
// A forced API host may carry an explicit :port (it goes straight
// into the API URL); the MQ host must NOT, because the dialer appends
// WithMessagingPort — a "host:port" MQ value would build
// "host:port:port" and fail every connection.
if err := validateForcedHost("WithAPIHost", cfg.forcedAPIHost, true); err != nil {
return err
}
if err := validateForcedHost("WithMQHost", cfg.forcedMQHost, false); err != nil {
return err
}
return validateConfigBounds(cfg)
}
// validateRegion structurally validates the WithRegion value BEFORE it is
// interpolated into the derived endpoint authorities
// (types.Environment.APIEndpoint builds e.g.
// "api-mq.integration.<region>oddin.gg"). Region is an open string —
// custom/new regions are allowed — but it MUST be either empty (default
// region) or a dot-TERMINATED sequence of valid DNS labels
// ("ap-southeast-1."). Anything else is an authority-injection vector:
// a crafted region like "x@evil.example/" turns the derived URL into
// https://api-mq.integration.x@evil.example/oddin.gg... — net/url reads
// the prefix as USERINFO and evil.example as the HOST, and New's
// synchronous who-am-i probe would send X-Access-Token to the
// attacker's TLS host. The trailing-dot requirement is itself
// security-relevant: "eu" (no dot) would silently retarget the base
// domain to "euoddin.gg".
func validateRegion(region types.Region) error {
r := string(region)
if r == "" {
return nil
}
if !strings.HasSuffix(r, ".") {
return fmt.Errorf("%w: WithRegion %q must end with '.' (e.g. \"ap-southeast-1.\") — it is a DNS-label prefix of the endpoint host", ErrInvalidConfig, r)
}
// isValidHostname permits exactly one trailing root dot, so the
// dot-terminated region validates directly: every label must be
// 1..63 chars of [A-Za-z0-9-] with no leading/trailing hyphen —
// which structurally excludes every authority delimiter
// ('@', '/', '?', '#', ':', whitespace, …).
if !isValidHostname(r) {
return fmt.Errorf("%w: WithRegion %q is not a valid DNS-label prefix", ErrInvalidConfig, r)
}
return nil
}
// validateForcedHost structurally validates a forced host override. Empty
// means "derive from environment/region" and is left alone. A scheme is
// already rejected at option-application time (stripOrRejectScheme); this
// catches the remaining ways a value can be malformed. When allowPort is
// false (MQ), an embedded port is rejected; when true (API), a port is
// allowed but must be numeric and in range. The host/IP literal itself
// is validated, so colon-bearing garbage (repeated ports, unbracketed
// IPv6) and malformed IPv6 brackets are rejected rather than passing
// through to fail later at URL parse or dial time.
func validateForcedHost(opt, host string, allowPort bool) error {
if host == "" {
return nil
}
if strings.ContainsAny(host, " \t\r\n\v\f") {
return fmt.Errorf("%w: %s %q must not contain whitespace", ErrInvalidConfig, opt, host)
}
// '/', '?', '#' are URL path/query/fragment; '@' is userinfo. None
// belong in a bare host.
if i := strings.IndexAny(host, "/?#@"); i >= 0 {
return fmt.Errorf("%w: %s %q must be a bare host, not a URL (found %q)", ErrInvalidConfig, opt, host, host[i:i+1])
}
var hostLiteral string
h, port, err := net.SplitHostPort(host)
switch {
case err == nil:
// An explicit :port is present (SplitHostPort strips the brackets
// around an IPv6, so h is the bare literal).
if !allowPort {
return fmt.Errorf("%w: %s %q must not include a port; set the port via WithMessagingPort", ErrInvalidConfig, opt, host)
}
// net/url permits ONLY ASCII digits in a port, so reject a sign
// or other stray character up front (strconv.Atoi would accept
// "+443"/"-443" and let it fail later at URL construction).
if !isAllDigits(port) {
return fmt.Errorf("%w: %s %q has a non-numeric port %q", ErrInvalidConfig, opt, host, port)
}
p, perr := strconv.Atoi(port)
if perr != nil || p < 1 || p > 65535 {
return fmt.Errorf("%w: %s %q has an invalid port %q (want 1..65535)", ErrInvalidConfig, opt, host, port)
}
hostLiteral = h
default:
// SplitHostPort fails for MORE than just "no port": "too many
// colons" (unbracketed IPv6 / repeated ports) and "missing ']'"
// (malformed brackets) are malformed inputs, not bare hosts. Only
// the "missing port in address" error means a genuine no-port host.
var addrErr *net.AddrError
if !errors.As(err, &addrErr) || addrErr.Err != "missing port in address" {
return fmt.Errorf("%w: %s %q is not a valid host: %w", ErrInvalidConfig, opt, host, err)
}
// Bare host, no port. A bracketed value must be a valid IPv6.
if strings.HasPrefix(host, "[") {
if !strings.HasSuffix(host, "]") {
return fmt.Errorf("%w: %s %q has malformed IPv6 brackets", ErrInvalidConfig, opt, host)
}
inner := host[1 : len(host)-1]
if ip := net.ParseIP(inner); ip == nil || ip.To4() != nil {
return fmt.Errorf("%w: %s %q is not a valid bracketed IPv6 address", ErrInvalidConfig, opt, host)
}
return nil
}
hostLiteral = host
}
// hostLiteral is a hostname or an unbracketed IP literal.
if net.ParseIP(hostLiteral) != nil {
return nil
}
if !isValidHostname(hostLiteral) {
return fmt.Errorf("%w: %s %q is not a valid hostname or IP address", ErrInvalidConfig, opt, host)
}
return nil
}
// isAllDigits reports whether s is non-empty and every byte is an ASCII
// digit — matching net/url's port rule (0-9 only, no sign).
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// isValidHostname reports whether h is a syntactically valid DNS
// hostname: 1..253 chars total, dot-separated labels of 1..63 chars,
// each label alphanumeric or hyphen with no leading/trailing hyphen. A
// single trailing dot (absolute/FQDN form, e.g. "api.example.com.") is
// permitted — net/url and DNS both accept it.
func isValidHostname(h string) bool {
if h == "" || len(h) > 254 {
return false
}
// Permit exactly one trailing root dot; more than one leaves an
// empty final label and is rejected below.
h = strings.TrimSuffix(h, ".")
if h == "" {
return false
}
for _, label := range strings.Split(h, ".") {
if label == "" || len(label) > 63 {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '-':
if i == 0 || i == len(label)-1 {
return false
}
default:
return false
}
}
}
return true
}
// validateConfigBounds rejects out-of-range numeric / enum / required-
// string options up front so they surface as ErrInvalidConfig from New
// rather than as confusing runtime failures later (a negative inactivity
// marks producers down immediately; a non-positive shutdown timeout
// makes graceful Close deadline-fail at once; an out-of-range port fails
// only when AMQP dials). Zero-semantics per option:
// - durations with a NewConfig default MUST be > 0 (the default is
// already applied; an explicit 0/negative is a mistake);
// - initialSnapshotTime is "0 = use default", so only < 0 is rejected;
// - sizes (prefetch, buffer, body limit) are "0 = default/none", so
// only < 0 is rejected;
// - port must be in 1..65535; enums must be a defined value; the
// default locale and exchange/prefix names must be non-empty.
func validateConfigBounds(cfg *Config) error {
type durCheck struct {
name string
d time.Duration
}
for _, c := range []durCheck{
{"WithMaxInactivity", cfg.maxInactivity},
{"WithMaxRecoveryExecution", cfg.maxRecoveryExecution},
{"WithHTTPClientTimeout", cfg.httpClientTimeout},
{"WithShutdownTimeout", cfg.shutdownTimeout},
} {
if c.d <= 0 {
return fmt.Errorf("%w: %s must be > 0, got %v", ErrInvalidConfig, c.name, c.d)
}
}
if cfg.initialSnapshotTime < 0 {
return fmt.Errorf("%w: WithInitialSnapshotTime must be >= 0, got %v", ErrInvalidConfig, cfg.initialSnapshotTime)
}
if cfg.messagingPort < 1 || cfg.messagingPort > 65535 {
return fmt.Errorf("%w: WithMessagingPort must be in 1..65535, got %d", ErrInvalidConfig, cfg.messagingPort)
}
type sizeCheck struct {
name string
n int
}
for _, c := range []sizeCheck{
{"WithAMQPPrefetch", cfg.amqpPrefetch},
{"WithSubscriptionBuffer", cfg.subscriptionBuffer},
{"WithAPICallBodyLimit", cfg.apiCallBodyLimit},
} {
if c.n < 0 {
return fmt.Errorf("%w: %s must be >= 0, got %d", ErrInvalidConfig, c.name, c.n)
}
}
// Upper bound is protocol-level: AMQP basic.qos carries the count as
// uint16, and amqp091-go truncates the int — 65536 wraps to protocol
// value 0, which means UNLIMITED prefetch (the exact opposite of the
// documented backpressure bound: a stalled consumer would let the
// dependency buffer deliveries in an unbounded slice), and 65537
// wraps to 1. Reject anything a uint16 can't carry. (0 stays valid:
// the SDK normalizes it to the default before it reaches Qos.)
if cfg.amqpPrefetch > 65535 {
return fmt.Errorf("%w: WithAMQPPrefetch must be in 0..65535 (AMQP basic.qos carries prefetch as uint16), got %d", ErrInvalidConfig, cfg.amqpPrefetch)
}
// Practical ceiling: the buffer is allocated per subscription AFTER
// broker setup (make(chan types.SessionMessage, n)) — an extreme
// accepted value panicked or OOMed only once external side effects
// (queue, bindings) already existed. 2^20 messages is far beyond any
// sane consumer lag budget.
if cfg.subscriptionBuffer > maxSubscriptionBuffer {
return fmt.Errorf("%w: WithSubscriptionBuffer must be <= %d, got %d", ErrInvalidConfig, maxSubscriptionBuffer, cfg.subscriptionBuffer)
}
if cfg.exceptionStrategy != StrategyCatch && cfg.exceptionStrategy != StrategyThrow {
return fmt.Errorf("%w: WithExceptionStrategy has unknown value %d", ErrInvalidConfig, cfg.exceptionStrategy)
}
if cfg.apiCallLogging < APILogOff || cfg.apiCallLogging > APILogFull {
return fmt.Errorf("%w: WithAPICallLogging has unknown value %d", ErrInvalidConfig, cfg.apiCallLogging)
}
if cfg.defaultLocale == "" {
return fmt.Errorf("%w: WithDefaultLocale must not be empty", ErrInvalidConfig)
}
if cfg.httpClient != nil && cfg.httpClient.Timeout <= 0 {
// Belt-and-braces: the option panics on a zero Timeout and the
// stored client is a private copy, but revalidate the ACTUAL
// stored value so no construction path can smuggle in an
// unbounded client.
return fmt.Errorf("%w: WithHTTPClient client must have Timeout > 0", ErrInvalidConfig)
}
for _, l := range cfg.preloadLocales {
if l == "" {
// An empty preload locale slipped past validation and made
// New issue malformed catalog requests (/sports//...).
return fmt.Errorf("%w: WithPreloadLocales contains an empty locale", ErrInvalidConfig)
}
}
type strCheck struct {
name, v string
}
for _, c := range []strCheck{
{"WithExchangeName", cfg.exchangeName},
{"WithReplayExchangeName", cfg.replayExchangeName},
{"WithSportIDPrefix", cfg.sportIDPrefix},
} {
if c.v == "" {
return fmt.Errorf("%w: %s must not be empty", ErrInvalidConfig, c.name)
}
}
// The sport-id prefix is concatenated with a numeric routing-key
// segment and parsed as a URN on EVERY feed delivery (parseRoute).
// A malformed prefix (e.g. "sport:") passes the non-empty check but
// makes every ordinary delivery unparsable — and under StrategyCatch
// those surface as UnparsableMessage and are ACKed, so the breakage
// is silent and unrecoverable. Require the exact two-component
// "prefix:type:" shape: appending an id must parse, and the id must
// round-trip untouched (u.ID==0 plus the trailing-colon check reject
// prefixes that end in digits, e.g. "od:sport:0", which would
// silently rewrite every sport id by concatenation).
if u, err := types.ParseURN(cfg.sportIDPrefix + "0"); err != nil || u.ID != 0 || !strings.HasSuffix(cfg.sportIDPrefix, ":") {
return fmt.Errorf("%w: WithSportIDPrefix %q must have the form \"prefix:type:\" (e.g. \"od:sport:\") so that appending a numeric id yields a valid URN", ErrInvalidConfig, cfg.sportIDPrefix)
}
// Node ids become routing-key segments (snapshot_complete.<id>,
// base.<id>.#) and node_id query parameters for recovery/replay —
// the server protocol has no notion of a negative node.
if cfg.sdkNodeID != nil && *cfg.sdkNodeID < 0 {
return fmt.Errorf("%w: WithNodeID must be >= 0, got %d", ErrInvalidConfig, *cfg.sdkNodeID)
}
return nil
}
// resetConnectionLayer (re)creates the one-shot rabbitMQClient +
// recoveryManager. Called once from New for the first attempt and
// again from ensure* rollback paths after a transient failure left
// either component in its terminal Closed state.
//
// Safe to call under lifecycleMu (the typical rollback case) or before
// any goroutines exist (New). Concurrent reads from public Recover* /
// ProducerStatus see either generation atomically — that's tolerable
// because those calls only need the latest, not a generation-paired
// pair (Subscribe and Connect get coherent pairs via runtime snapshots).
func (c *Client) resetConnectionLayer() {
c.recoveryManager.Store(recovery.NewManager(c.cfgAdpt, c.producerManager, c.apiClient, c.logger, c.cfg.initialSnapshotTime))
rmq := feed.NewClient(c.cfgAdpt, c.whoAmIManager, c.logger)
rmq.SetEventEmitter(c.onFeedEvent)
c.rabbitMQClient.Store(rmq)
}
// setMode is the only writer of mode + connectState. Caller MUST hold
// lifecycleMu. The atomic.Int32 mirror is updated alongside so
// ConnectionState() readers see a coherent edge with the mode change.
func (c *Client) setMode(m clientMode) {
c.mode = m
c.connectState.Store(int32(m.publicState()))
}
// rollbackPartialNormal undoes a partially-built ensureNormal pipeline
// without disturbing replay subscriptions that share the broker.
//
// Honors priorMode:
// - priorMode == modeBrokerOnly: rmq stays open and is NOT replaced.
// Existing replay subscriptions remain functional. Only the
// normal-only pieces (alive session, recovery manager, internal
// ctx) are torn down; recoveryManager is replaced with a fresh
// instance so a retry ensureNormal sees clean state.
// - priorMode == modeNew: full reset. rmq is closed and the
// connection layer is recreated for a clean retry.
//
// alive is closed only if non-nil — pass the local newSession() result
// from the alive-Open failure path; pass nil from the recovery-Open
// path. session.Close is safe even if Open failed (closeOnce + nil
// checks on closeFn/done; ChannelConsumer.Close is idempotent).
//
// rmgr is always closed when non-nil. Pass the local
// recoveryManager.Load() from the attempt; pass nil from the rmq.Open
// failure path (rmgr was never opened).
func (c *Client) rollbackPartialNormal(
ctx context.Context,
alive sdkOddsFeedSession,
rmgr *recovery.Manager,
rmq *feed.Client,
priorMode clientMode,
) {
// Read-and-clear under lifecycleMu like every other internalCancel
// access: an attempt in rollback never owns a PUBLISHED
// internalCancel (publication happens only on the modeNormalReady
// path, and the attempt's own cancel is still held locally by the
// cancelPublished defer), so both sides only ever see nil today —
// but without the lock there is no happens-before edge against
// runShutdown's read after a shutdown-timeout race, and the
// invariant keeping the bare access benign is one refactor away
// from breaking.
c.lifecycleMu.Lock()
if c.internalCancel != nil {
c.internalCancel()
c.internalCancel = nil
}
c.lifecycleMu.Unlock()
shutdownCtx, sCancel := context.WithTimeout(context.WithoutCancel(ctx), c.cfg.shutdownTimeout)
defer sCancel()
if alive != nil {
alive.Close(shutdownCtx)
}
if rmgr != nil {
// Bounded by the rollback's shutdown budget — same rationale as
// runShutdown: a wedged actor must not pin the rollback.
rmgr.CloseCtx(shutdownCtx)
}
if priorMode == modeBrokerOnly {
// rmq stays — replay subscriptions are still attached to it via
// their session's channelConsumer. Replace ONLY the
// recovery.Manager (it's normal-only; replay never sees it).
c.lifecycleMu.Lock()
if c.mode != modeClosing && c.mode != modeClosed {
c.recoveryManager.Store(recovery.NewManager(c.cfgAdpt, c.producerManager, c.apiClient, c.logger, c.cfg.initialSnapshotTime))
}
c.lifecycleMu.Unlock()
return
}
// priorMode == modeNew: full reset. Close rmq bounded by
// cfg.shutdownTimeout so a stuck broker doesn't hang the rollback.
_ = rmq.Close(shutdownCtx)
c.lifecycleMu.Lock()
// NEVER install a fresh connection generation once Closing/Closed:
// runShutdown's one-shot teardown has already run (or is running) —
// nothing would ever close the replacements, and a rollback resumed
// AFTER a timed-out shutdown would mutate a client that has already
// published Closed. The terminal generation stays in place; retry
// paths are gone anyway (Connect after Close is rejected at
// admission).
if c.mode != modeClosing && c.mode != modeClosed {
c.resetConnectionLayer()
}
c.lifecycleMu.Unlock()
}
// snapshotRuntime captures (rmq, rmgr) under the caller's lifecycleMu.
// Returned to ensureBroker / ensureNormal callers so all subsequent
// reads in the flow operate on the same generation, even if a
// concurrent rollback swaps the atomic pointers.
func (c *Client) snapshotRuntime() runtime {
return runtime{
rmq: c.rabbitMQClient.Load(),
rmgr: c.recoveryManager.Load(),
}
}
// ensureBroker is the replay path's lifecycle entry. It guarantees
// AMQP is open without standing up the producer/recovery/alive-session
// pipeline. Idempotent: multiple replay subscriptions share one
// underlying feed.Client. Compatible with ensureNormal: if a normal
// Connect is in flight or done, the existing rmq is reused.
func (c *Client) ensureBroker(ctx context.Context) (runtime, error) {
c.lifecycleMu.Lock()
// Wait for any in-flight ensureNormal to settle before adopting rmq.
//
// Why: a normal Connect that fails at recovery/alive setup runs
// rollbackPartialNormal, which (when priorMode == modeNew) closes
// rmq and replaces it via resetConnectionLayer. If we proceeded
// during modeNormalConnecting, we would adopt that rmq, build a
// session against it, possibly admit a subscription — and then the
// rollback would tear it out from under us. Sequencing ensureBroker
// after the in-flight attempt settles guarantees we see the
// post-rollback (or post-success) mode + rmq, never an
// about-to-be-replaced one.
//
// Loop, not single-wait: a fresh ensureNormal could start between
// the wakeup and the re-acquire, putting us back into
// modeNormalConnecting. The loop drains all consecutive attempts.
for c.mode == modeNormalConnecting {
done := c.connectDone
c.lifecycleMu.Unlock()
select {
case <-done:
case <-ctx.Done():
return runtime{}, ctx.Err()
}
c.lifecycleMu.Lock()
}
var transitioned bool
switch c.mode {
case modeClosing, modeClosed:
c.lifecycleMu.Unlock()
return runtime{}, ErrAlreadyClosed
case modeBrokerOnly, modeNormalReady:
// AMQP already up. rmq.Open below is idempotent.
case modeNew:
c.setMode(modeBrokerOnly)
transitioned = true
case modeNormalConnecting:
// Unreachable: the wait loop above drained this state.
}
rt := c.snapshotRuntime()
// Add under lifecycleMu while mode is not Closing/Closed — pairs
// with beginClose's mode transition + runShutdown's brokerOpenWG.Wait
// to fence broker open against shutdown's broker teardown.
c.brokerOpenWG.Add(1)
c.lifecycleMu.Unlock()
defer c.brokerOpenWG.Done()
// Make the broker dial cancellable by the client lifetime: shutdown
// cancels lifetimeCtx (runShutdown, top), aborting an in-flight dial
// instead of letting it run for feed.Client's 30s fallback and blow
// past WithShutdownTimeout. The caller's ctx still bounds it too.
openCtx, cancelOpen := context.WithCancel(ctx)
defer cancelOpen()
//nolint:contextcheck // lifetimeCtx is the client-lifetime cancellation root wired to abort this dial on shutdown, not a request parent
stopLife := context.AfterFunc(c.lifetimeCtx, cancelOpen)
defer stopLife()
openErr := rt.rmq.Open(openCtx)
// Reconcile the root mode against the ACTUAL broker state, resolving
// concurrent ensureBroker generations regardless of the order their
// lifecycleMu sections interleave. RETAIN the connection layer on
// failure — do NOT resetConnectionLayer: a failed feed Open leaves the
// client retryable-unopened (round-8 fix), and replacing it would
// orphan a concurrent caller's still-in-flight open of the same
// generation.
c.lifecycleMu.Lock()
c.reconcileBrokerModeLocked(transitioned, rt.rmq.IsOpen())
c.lifecycleMu.Unlock()