feat: implement relay pool ban logic and enhance discovery failure handling
Kim committed
May 19, 2026 at 16:38 UTC
a16326a4fbf8f8c2fa326762f8b86ebd5322e015
4 files changed
+281
-87
portal/discovery/refresher.go
+5
-23
@@ -136,31 +136,9 @@ func (r *Refresher) shouldLogAnnounce(relayURL string, success bool) bool {
136
}
137
138
func (r *Refresher) refreshHTTPS(ctx context.Context) error {
139
- r.relaySet.mu.RLock()
140
- states := make([]RelayState, 0, len(r.relaySet.relays))
141
- for _, state := range r.relaySet.relays {
142
- states = append(states, state)
143
- }
144
- r.relaySet.mu.RUnlock()
145
-
139
now := time.Now().UTC()
147
- for _, state := range states {
148
- if state.Banned {
149
- continue
150
- }
151
- if !state.hasObservedDescriptor() {
152
- if !state.Bootstrap {
153
- continue
154
- }
155
- } else if !state.Bootstrap {
156
- if !state.nextDiscoveryRefreshAt.IsZero() && state.nextDiscoveryRefreshAt.After(now) {
157
- continue
158
- }
159
- }
140
+ for _, state := range r.relaySet.refreshCandidates(now) {
141
relayURL := state.Descriptor.APIHTTPSAddr
161
- if relayURL == "" {
162
- continue
163
- }
142
143
recoveryFailures := r.directRecoveryFailures
144
if state.Bootstrap {
@@ -265,5 +243,9 @@ func (r *Refresher) logDiscoveryFailure(targetRelayURL, sourceURL string, recove
243
if failureCount > 0 {
244
event = event.Int("discovery_failures", failureCount)
245
}
246
+ if backoffReason == "unhealthy" {
247
+ event.Msg("discovery source removed from relay pool")
248
+ return
249
+ }
250
event.Msg("discovery source retry delayed")
251
}
portal/discovery/relayset.go
+160
-64
@@ -76,6 +76,110 @@ func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
76
return set
77
}
78
79
+// currentRelayStates returns a copy of the set after expiring temporary pool bans.
80
+func (s *RelaySet) currentRelayStates(now time.Time) []RelayState {
81
+ if now.IsZero() {
82
+ now = time.Now().UTC()
83
+ }
84
+ s.mu.Lock()
85
+ defer s.mu.Unlock()
86
+
87
+ s.clearExpiredPoolBansLocked(now)
88
+ states := make([]RelayState, 0, len(s.relays))
89
+ for _, state := range s.relays {
90
+ states = append(states, state)
91
+ }
92
+ return states
93
+}
94
+
95
+// refreshCandidates returns relays worth directly polling after applying local
96
+// pool-ban expiry. The refresher owns HTTP; RelaySet owns pool eligibility.
97
+func (s *RelaySet) refreshCandidates(now time.Time) []RelayState {
98
+ if now.IsZero() {
99
+ now = time.Now().UTC()
100
+ }
101
+ states := s.currentRelayStates(now)
102
+ out := make([]RelayState, 0, len(states))
103
+ for _, state := range states {
104
+ if state.Banned {
105
+ continue
106
+ }
107
+ if !state.hasObservedDescriptor() {
108
+ if !state.Bootstrap {
109
+ continue
110
+ }
111
+ } else if !state.Bootstrap {
112
+ if !state.nextDiscoveryRefreshAt.IsZero() && state.nextDiscoveryRefreshAt.After(now) {
113
+ continue
114
+ }
115
+ }
116
+ if state.Descriptor.APIHTTPSAddr == "" {
117
+ continue
118
+ }
119
+ out = append(out, state)
120
+ }
121
+ return out
122
+}
123
+
124
+func (s *RelaySet) clearExpiredPoolBansLocked(now time.Time) {
125
+ for relayURL, state := range s.relays {
126
+ if !state.Banned || state.suppressActiveUntil.IsZero() || state.suppressActiveUntil.After(now) {
127
+ continue
128
+ }
129
+ if !state.Bootstrap {
130
+ delete(s.relays, relayURL)
131
+ continue
132
+ }
133
+ state.Banned = false
134
+ state.suppressActiveUntil = time.Time{}
135
+ state.unhealthySince = time.Time{}
136
+ s.relays[relayURL] = state
137
+ }
138
+}
139
+
140
+func (s *RelaySet) banFromPoolLocked(relayURL string, now time.Time) {
141
+ if relayURL == "" {
142
+ return
143
+ }
144
+ bootstrap := false
145
+ state, ok := s.relays[relayURL]
146
+ if ok {
147
+ bootstrap = state.Bootstrap
148
+ }
149
+ state = newRelayState(relayURL)
150
+ state.Bootstrap = bootstrap
151
+ state.Banned = true
152
+ state.suppressActiveUntil = now.Add(relayPoolBanTTL)
153
+ s.relays[relayURL] = state
154
+}
155
+
156
+func mergeLocalRelayState(record, existing RelayState) RelayState {
157
+ record.Bootstrap = record.Bootstrap || existing.Bootstrap
158
+ record.Confirmed = record.Confirmed || existing.Confirmed
159
+ record.Banned = record.Banned || existing.Banned
160
+ if record.discoveryFailures < existing.discoveryFailures {
161
+ record.discoveryFailures = existing.discoveryFailures
162
+ }
163
+ if record.activeFailures < existing.activeFailures {
164
+ record.activeFailures = existing.activeFailures
165
+ }
166
+ record.unhealthySince = existing.unhealthySince
167
+ record.nextDiscoveryRefreshAt = existing.nextDiscoveryRefreshAt
168
+ record.suppressActiveUntil = existing.suppressActiveUntil
169
+ if record.DiscoveryRTTAt.IsZero() || (!existing.DiscoveryRTTAt.IsZero() && existing.DiscoveryRTTAt.After(record.DiscoveryRTTAt)) {
170
+ record.DiscoveryRTT = existing.DiscoveryRTT
171
+ record.DiscoveryRTTAt = existing.DiscoveryRTTAt
172
+ }
173
+ return record
174
+}
175
+
176
+func markDiscoveryConfirmed(state RelayState) RelayState {
177
+ state.discoveryFailures = 0
178
+ state.nextDiscoveryRefreshAt = time.Time{}
179
+ state.unhealthySince = time.Time{}
180
+ return state
181
+}
182
+
183
// upsertDescriptorLocked applies a fully-merged RelayState to s.relays and
184
// updates the keyIndex. The caller MUST already hold s.mu as a write lock.
185
//
@@ -103,6 +207,9 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
207
if relayURL == "" {
208
return upsertRejected
209
}
210
+ if existing, ok := s.relays[relayURL]; ok && existing.Banned {
211
+ return upsertRejected
212
+ }
213
address := strings.ToLower(strings.TrimSpace(record.Descriptor.Address))
214
if address != "" {
215
if prev, ok := s.keyIndex[address]; ok {
@@ -157,6 +264,9 @@ func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) {
264
s.mu.Lock()
265
defer s.mu.Unlock()
266
267
+ now := time.Now().UTC()
268
+ s.clearExpiredPoolBansLocked(now)
269
+
270
keep := make(map[string]struct{}, len(inputs))
271
for _, relayURL := range inputs {
272
keep[relayURL] = struct{}{}
@@ -176,7 +286,9 @@ func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) {
286
}
287
288
for _, relayURL := range inputs {
179
- if _, ok := s.relays[relayURL]; ok {
289
+ if state, ok := s.relays[relayURL]; ok {
290
+ state.Bootstrap = true
291
+ s.relays[relayURL] = state
292
continue
293
}
294
@@ -237,12 +349,7 @@ func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]R
349
return []Route{NewRoute(explicitPath, true)}, nil
350
}
351
240
- s.mu.RLock()
241
- states := make([]RelayState, 0, len(s.relays))
242
- for _, state := range s.relays {
243
- states = append(states, state)
244
- }
245
- s.mu.RUnlock()
352
+ states := s.currentRelayStates(time.Now().UTC())
353
354
if routeState.MultiHopDepth > 1 {
355
path := selectMultiHop(states, routeState)
@@ -262,15 +369,14 @@ func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]R
369
370
func (s *RelaySet) OverlayPeerStates() []RelayState {
371
now := time.Now().UTC()
265
- s.mu.RLock()
266
- out := make([]RelayState, 0, len(s.relays))
267
- for _, state := range s.relays {
372
+ states := s.currentRelayStates(now)
373
+ out := make([]RelayState, 0, len(states))
374
+ for _, state := range states {
375
if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
376
continue
377
}
378
out = append(out, state)
379
}
273
- s.mu.RUnlock()
380
if len(out) == 0 {
381
return nil
382
}
@@ -297,9 +403,9 @@ func (s *RelaySet) OverlayRelayDescriptor(relayURL string, now time.Time) (types
403
// BootstrapRelayURLs returns configured bootstrap discovery endpoints that
404
// can receive this relay's periodic self-announce.
405
func (s *RelaySet) BootstrapRelayURLs() []string {
300
- s.mu.RLock()
301
- out := make([]string, 0, len(s.relays))
302
- for _, state := range s.relays {
406
+ states := s.currentRelayStates(time.Now().UTC())
407
+ out := make([]string, 0, len(states))
408
+ for _, state := range states {
409
if state.Banned || !state.Bootstrap {
410
continue
411
}
@@ -309,7 +415,6 @@ func (s *RelaySet) BootstrapRelayURLs() []string {
415
}
416
out = append(out, relayURL)
417
}
312
- s.mu.RUnlock()
418
if len(out) == 0 {
419
return nil
420
}
@@ -338,14 +443,12 @@ func (s *RelaySet) Descriptors(self types.RelayDescriptor) []types.RelayDescript
443
if self.APIHTTPSAddr != "" && self.ExpiresAt.After(now) {
444
add(self)
445
}
341
- s.mu.RLock()
342
- for _, state := range s.relays {
446
+ for _, state := range s.currentRelayStates(now) {
447
if state.Banned || !state.hasObservedDescriptor() {
448
continue
449
}
450
add(state.Descriptor)
451
}
348
- s.mu.RUnlock()
452
if len(out) == 0 {
453
return nil
454
}
@@ -360,6 +463,7 @@ func (s *RelaySet) BanRelayURL(relayURL string) {
463
if !ok {
464
state = newRelayState(relayURL)
465
}
466
+ state.suppressActiveUntil = time.Time{}
467
state.Banned = true
468
s.relays[relayURL] = state
469
}
@@ -368,10 +472,16 @@ func (s *RelaySet) ConfirmRelayURL(relayURL string) {
472
s.mu.Lock()
473
defer s.mu.Unlock()
474
475
+ now := time.Now().UTC()
476
+ s.clearExpiredPoolBansLocked(now)
477
state, ok := s.relays[relayURL]
478
if !ok {
479
state = newRelayState(relayURL)
480
}
481
+ if state.Banned {
482
+ s.relays[relayURL] = state
483
+ return
484
+ }
485
state.Confirmed = true
486
state.activeFailures = 0
487
state.suppressActiveUntil = time.Time{}
@@ -401,6 +511,7 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
511
512
s.mu.Lock()
513
defer s.mu.Unlock()
514
+ s.clearExpiredPoolBansLocked(now)
515
516
discoveredByURL := make(map[string]RelayState, len(resp.Relays))
517
discoveredOrder := make([]string, 0, len(resp.Relays)+1)
@@ -426,6 +537,9 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
537
if relayURL == "" {
538
return
539
}
540
+ if existing, ok := s.relays[relayURL]; ok && existing.Banned {
541
+ return
542
+ }
543
if authoritative && relayURL == targetURL {
544
targetFound = true
545
}
@@ -442,26 +556,11 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
556
for _, relayURL := range discoveredOrder {
557
record := discoveredByURL[relayURL]
558
existingAtURL, hasExistingAtURL := s.relays[relayURL]
445
- record.Bootstrap = record.Bootstrap || existingAtURL.Bootstrap
446
- record.Confirmed = record.Confirmed || existingAtURL.Confirmed
447
- record.Banned = record.Banned || existingAtURL.Banned
448
- if record.discoveryFailures < existingAtURL.discoveryFailures {
449
- record.discoveryFailures = existingAtURL.discoveryFailures
450
- }
451
- if record.activeFailures < existingAtURL.activeFailures {
452
- record.activeFailures = existingAtURL.activeFailures
453
- }
454
- record.nextDiscoveryRefreshAt = existingAtURL.nextDiscoveryRefreshAt
455
- record.suppressActiveUntil = existingAtURL.suppressActiveUntil
456
- if record.DiscoveryRTTAt.IsZero() || (!existingAtURL.DiscoveryRTTAt.IsZero() && existingAtURL.DiscoveryRTTAt.After(record.DiscoveryRTTAt)) {
457
- record.DiscoveryRTT = existingAtURL.DiscoveryRTT
458
- record.DiscoveryRTTAt = existingAtURL.DiscoveryRTTAt
459
- }
559
+ record = mergeLocalRelayState(record, existingAtURL)
560
561
isAuthoritativeTarget := !protocolMismatch && !missingTarget && authoritative && relayURL == targetURL
562
if isAuthoritativeTarget {
463
- record.discoveryFailures = 0
464
- record.nextDiscoveryRefreshAt = time.Time{}
563
+ record = markDiscoveryConfirmed(record)
564
}
565
566
if upsert := s.upsertDescriptorLocked(record, now, isAuthoritativeTarget); upsert != upsertAccepted {
@@ -472,9 +571,8 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
571
// authoritative target we should still credit it as alive on its
572
// existing URL slot.
573
if isAuthoritativeTarget && hasExistingAtURL {
475
- if existingAtURL.discoveryFailures != 0 || !existingAtURL.nextDiscoveryRefreshAt.IsZero() {
476
- existingAtURL.discoveryFailures = 0
477
- existingAtURL.nextDiscoveryRefreshAt = time.Time{}
574
+ if existingAtURL.discoveryFailures != 0 || !existingAtURL.nextDiscoveryRefreshAt.IsZero() || !existingAtURL.unhealthySince.IsZero() {
575
+ existingAtURL = markDiscoveryConfirmed(existingAtURL)
576
s.relays[relayURL] = existingAtURL
577
relaySetChanged = true
578
}
@@ -555,24 +653,14 @@ func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) er
653
654
s.mu.Lock()
655
defer s.mu.Unlock()
656
+ s.clearExpiredPoolBansLocked(now)
657
658
relayURL := record.Descriptor.APIHTTPSAddr
659
+ if existing, ok := s.relays[relayURL]; ok && existing.Banned {
660
+ return errors.New("relay banned from pool")
661
+ }
662
if existing, ok := s.relays[relayURL]; ok {
561
- record.Bootstrap = record.Bootstrap || existing.Bootstrap
562
- record.Confirmed = record.Confirmed || existing.Confirmed
563
- record.Banned = record.Banned || existing.Banned
564
- if record.discoveryFailures < existing.discoveryFailures {
565
- record.discoveryFailures = existing.discoveryFailures
566
- }
567
- if record.activeFailures < existing.activeFailures {
568
- record.activeFailures = existing.activeFailures
569
- }
570
- record.nextDiscoveryRefreshAt = existing.nextDiscoveryRefreshAt
571
- record.suppressActiveUntil = existing.suppressActiveUntil
572
- if record.DiscoveryRTTAt.IsZero() || (!existing.DiscoveryRTTAt.IsZero() && existing.DiscoveryRTTAt.After(record.DiscoveryRTTAt)) {
573
- record.DiscoveryRTT = existing.DiscoveryRTT
574
- record.DiscoveryRTTAt = existing.DiscoveryRTTAt
575
- }
663
+ record = mergeLocalRelayState(record, existing)
664
}
665
666
switch s.upsertDescriptorLocked(record, now, false) {
@@ -614,6 +702,7 @@ func validateRelayDescriptorFreshness(desc types.RelayDescriptor, now time.Time)
702
// as a write lock.
703
func (s *RelaySet) enforceCapLocked() {
704
now := time.Now().UTC()
705
+ s.clearExpiredPoolBansLocked(now)
706
for address, entry := range s.keyIndex {
707
if !entry.TombstoneUntil.IsZero() && now.After(entry.TombstoneUntil) {
708
delete(s.keyIndex, address)
@@ -629,7 +718,7 @@ func (s *RelaySet) enforceCapLocked() {
718
}
719
candidates := make([]ageEntry, 0, len(s.relays))
720
for url, state := range s.relays {
632
- if state.Bootstrap {
721
+ if state.Bootstrap || state.Banned {
722
continue
723
}
724
candidates = append(candidates, ageEntry{
@@ -658,11 +747,20 @@ func (s *RelaySet) RecordDiscoveryFailure(relayURL string, recoveryFailures int)
747
s.mu.Lock()
748
defer s.mu.Unlock()
749
750
+ now := time.Now().UTC()
751
+ s.clearExpiredPoolBansLocked(now)
752
state, ok := s.relays[relayURL]
662
- if !ok {
753
+ if !ok || state.Banned {
754
return false, "", 0
755
}
756
state.discoveryFailures++
757
+ if state.unhealthySince.IsZero() {
758
+ state.unhealthySince = now
759
+ }
760
+ if !state.unhealthySince.Add(AnnounceMaxValidity).After(now) {
761
+ s.banFromPoolLocked(relayURL, now)
762
+ return true, "unhealthy", state.discoveryFailures
763
+ }
764
765
if recoveryFailures <= 0 || state.discoveryFailures < recoveryFailures {
766
s.relays[relayURL] = state
@@ -673,19 +771,19 @@ func (s *RelaySet) RecordDiscoveryFailure(relayURL string, recoveryFailures int)
771
if backoff > maxDirectRecoveryBackoff {
772
backoff = maxDirectRecoveryBackoff
773
}
676
- state.nextDiscoveryRefreshAt = time.Now().Add(backoff)
677
- backedOff = true
678
- backoffReason = "discovery"
774
+ state.nextDiscoveryRefreshAt = now.Add(backoff)
775
s.relays[relayURL] = state
680
- return backedOff, backoffReason, state.discoveryFailures
776
+ return true, "discovery", state.discoveryFailures
777
}
778
779
func (s *RelaySet) RecordActiveFailure(relayURL string, recoveryFailures int) (backedOff bool, backoffReason string, failureCount int) {
780
s.mu.Lock()
781
defer s.mu.Unlock()
782
783
+ now := time.Now().UTC()
784
+ s.clearExpiredPoolBansLocked(now)
785
state, ok := s.relays[relayURL]
688
- if !ok {
786
+ if !ok || state.Banned {
787
return false, "", 0
788
}
789
state.activeFailures++
@@ -699,9 +797,7 @@ func (s *RelaySet) RecordActiveFailure(relayURL string, recoveryFailures int) (b
797
if backoff > maxDirectRecoveryBackoff {
798
backoff = maxDirectRecoveryBackoff
799
}
702
- state.suppressActiveUntil = time.Now().Add(backoff)
703
- backedOff = true
704
- backoffReason = "active"
800
+ state.suppressActiveUntil = now.Add(backoff)
801
s.relays[relayURL] = state
706
- return backedOff, backoffReason, state.activeFailures
802
+ return true, "active", state.activeFailures
803
}
portal/discovery/relayset_test.go
+114
@@ -231,6 +231,120 @@ func TestUnconfirmRelayURLClearsLocalConfirmationOnly(t *testing.T) {
231
}
232
}
233
234
+func TestRecordDiscoveryFailurePoolBansLongUnhealthyRelay(t *testing.T) {
235
+ set := NewRelaySet(nil)
236
+
237
+ relayURL := "https://relay-unhealthy.example"
238
+ state := confirmedPolicyRelayState(t, relayURL)
239
+ state.unhealthySince = time.Now().UTC().Add(-AnnounceMaxValidity - time.Minute)
240
+
241
+ set.mu.Lock()
242
+ set.relays[relayURL] = state
243
+ set.mu.Unlock()
244
+
245
+ backedOff, reason, failures := set.RecordDiscoveryFailure(relayURL, 1)
246
+ if !backedOff || reason != "unhealthy" {
247
+ t.Fatalf("RecordDiscoveryFailure() = (%v, %q), want unhealthy pool ban", backedOff, reason)
248
+ }
249
+ if failures != 1 {
250
+ t.Fatalf("failure count = %d, want 1", failures)
251
+ }
252
+
253
+ set.mu.RLock()
254
+ quarantined := set.relays[relayURL]
255
+ set.mu.RUnlock()
256
+ if !quarantined.Banned {
257
+ t.Fatal("unhealthy relay was not pool-banned")
258
+ }
259
+ if quarantined.hasObservedDescriptor() {
260
+ t.Fatal("unhealthy relay descriptor should be removed from active relay pool")
261
+ }
262
+ if !quarantined.suppressActiveUntil.After(time.Now().UTC().Add(relayPoolBanTTL - time.Minute)) {
263
+ t.Fatalf("pool ban until = %v, want about %v from now", quarantined.suppressActiveUntil, relayPoolBanTTL)
264
+ }
265
+}
266
+
267
+func TestRecordActiveFailureDoesNotPoolBanLongUnhealthyRelay(t *testing.T) {
268
+ set := NewRelaySet(nil)
269
+
270
+ relayURL := "https://relay-active-unhealthy.example"
271
+ state := confirmedPolicyRelayState(t, relayURL)
272
+ state.unhealthySince = time.Now().UTC().Add(-AnnounceMaxValidity - time.Minute)
273
+
274
+ set.mu.Lock()
275
+ set.relays[relayURL] = state
276
+ set.mu.Unlock()
277
+
278
+ backedOff, reason, failures := set.RecordActiveFailure(relayURL, 1)
279
+ if !backedOff || reason != "active" {
280
+ t.Fatalf("RecordActiveFailure() = (%v, %q), want active backoff", backedOff, reason)
281
+ }
282
+ if failures != 1 {
283
+ t.Fatalf("failure count = %d, want 1", failures)
284
+ }
285
+
286
+ set.mu.RLock()
287
+ activeBackoff := set.relays[relayURL]
288
+ set.mu.RUnlock()
289
+ if activeBackoff.Banned {
290
+ t.Fatal("active failure should not pool-ban relay")
291
+ }
292
+ if !activeBackoff.hasObservedDescriptor() {
293
+ t.Fatal("active failure should not remove relay descriptor")
294
+ }
295
+ if activeBackoff.suppressActiveUntil.IsZero() {
296
+ t.Fatal("active failure should schedule active suppression")
297
+ }
298
+}
299
+
300
+func TestPoolBanRejectsDiscoveryUntilExpiry(t *testing.T) {
301
+ set := NewRelaySet(nil)
302
+
303
+ relayURL := "https://relay-quarantined.example"
304
+ desc := mustPolicyRelayDescriptor(t, relayURL)
305
+
306
+ set.mu.Lock()
307
+ state := newRelayState(relayURL)
308
+ state.Banned = true
309
+ state.suppressActiveUntil = time.Now().UTC().Add(time.Hour)
310
+ set.relays[relayURL] = state
311
+ set.mu.Unlock()
312
+
313
+ changed, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
314
+ ProtocolVersion: types.DiscoveryVersion,
315
+ Relays: []types.RelayDescriptor{desc},
316
+ }, time.Now().UTC())
317
+ if err != nil {
318
+ t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
319
+ }
320
+ if changed {
321
+ t.Fatal("pool-banned relay should not change relay set")
322
+ }
323
+ if got := relayStates(set); len(got) != 0 {
324
+ t.Fatalf("len(relayStates()) = %d, want 0", len(got))
325
+ }
326
+
327
+ set.mu.Lock()
328
+ state = set.relays[relayURL]
329
+ state.suppressActiveUntil = time.Now().UTC().Add(-time.Second)
330
+ set.relays[relayURL] = state
331
+ set.mu.Unlock()
332
+
333
+ changed, err = set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
334
+ ProtocolVersion: types.DiscoveryVersion,
335
+ Relays: []types.RelayDescriptor{desc},
336
+ }, time.Now().UTC())
337
+ if err != nil {
338
+ t.Fatalf("ApplyRelayDiscoveryResponse() after expiry error = %v", err)
339
+ }
340
+ if !changed {
341
+ t.Fatal("expired pool ban should allow relay set update")
342
+ }
343
+ if got := relayStates(set); len(got) != 1 || got[0].Descriptor.APIHTTPSAddr != relayURL {
344
+ t.Fatalf("relayStates() = %v, want relay %q", got, relayURL)
345
+ }
346
+}
347
+
348
func TestPlanRoutesExplicitPathReturnsSingleRouteToExit(t *testing.T) {
349
const (
350
entry = "https://entry.example"
portal/discovery/relaystate.go
+2
@@ -10,6 +10,7 @@ const (
10
DiscoveryDescriptorTTL = 5 * time.Minute
11
defaultDirectRecoveryBackoff = 1 * time.Minute
12
maxDirectRecoveryBackoff = 5 * time.Minute
13
+ relayPoolBanTTL = 72 * time.Hour
14
15
// MaxAnnouncedRelays is the hard ceiling on the number of relay entries
16
// the local set will retain. When exceeded, eviction prefers the oldest
@@ -41,6 +42,7 @@ type RelayState struct {
42
43
discoveryFailures int
44
activeFailures int
45
+ unhealthySince time.Time
46
nextDiscoveryRefreshAt time.Time
47
suppressActiveUntil time.Time
48
}