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 }