package relay import ( "context" "fmt" "log" "sort" "strings" "sync" "time" "git.mleku.dev/mleku/dendrite/pkg/nostr" ) // Pool tracks known relays and their popularity across the network. // Each time a relay appears in a user's kind-10002 relay list, its encounter // count increments. Relays that appear in many users' lists are tried first // when searching for content. type Pool struct { mu sync.Mutex relays map[string]*PoolRelayInfo primary string } // PoolRelayInfo tracks a single relay's statistics. type PoolRelayInfo struct { URL string `json:"url"` Encounters int `json:"encounters"` Hits int `json:"hits"` Misses int `json:"misses"` RateLimited int `json:"rate_limited"` MinInterval time.Duration `json:"min_interval"` LastRequest time.Time `json:"-"` } // NewPool creates a pool with the given primary relay. func NewPool(primary string) *Pool { primary = normalizeRelayURL(primary) return &Pool{ relays: map[string]*PoolRelayInfo{primary: {URL: primary}}, primary: primary, } } // Add registers a relay URL, incrementing its encounter count. func (rp *Pool) Add(url string) { url = normalizeRelayURL(url) if url == "" { return } rp.mu.Lock() defer rp.mu.Unlock() if ri, ok := rp.relays[url]; ok { ri.Encounters++ } else { rp.relays[url] = &PoolRelayInfo{URL: url, Encounters: 1} } } // RecordHit marks a successful fetch from a relay. func (rp *Pool) RecordHit(url string) { url = normalizeRelayURL(url) rp.mu.Lock() defer rp.mu.Unlock() if ri, ok := rp.relays[url]; ok { ri.Hits++ } } // RecordMiss marks a failed/empty fetch from a relay. func (rp *Pool) RecordMiss(url string) { url = normalizeRelayURL(url) rp.mu.Lock() defer rp.mu.Unlock() if ri, ok := rp.relays[url]; ok { ri.Misses++ } } // Ranked returns all relay URLs sorted by encounter count descending, // excluding the primary relay. func (rp *Pool) Ranked() []string { rp.mu.Lock() defer rp.mu.Unlock() type entry struct { url string count int } var entries []entry for url, ri := range rp.relays { if url == rp.primary { continue } entries = append(entries, entry{url, ri.Encounters}) } sort.Slice(entries, func(i, j int) bool { return entries[i].count > entries[j].count }) urls := make([]string, len(entries)) for i, e := range entries { urls[i] = e.url } return urls } // Size returns the number of known relays. func (rp *Pool) Size() int { rp.mu.Lock() defer rp.mu.Unlock() return len(rp.relays) } // Stats returns a summary of the relay pool. func (rp *Pool) Stats() string { rp.mu.Lock() defer rp.mu.Unlock() var b strings.Builder fmt.Fprintf(&b, "relay pool: %d relays\n", len(rp.relays)) type entry struct { url string encounters int hits int misses int } var entries []entry for _, ri := range rp.relays { entries = append(entries, entry{ri.URL, ri.Encounters, ri.Hits, ri.Misses}) } sort.Slice(entries, func(i, j int) bool { return entries[i].encounters > entries[j].encounters }) limit := 20 if len(entries) < limit { limit = len(entries) } for i := 0; i < limit; i++ { e := entries[i] fmt.Fprintf(&b, " %3d encounters, %3d hits, %3d misses: %s\n", e.encounters, e.hits, e.misses, e.url) } if len(entries) > limit { fmt.Fprintf(&b, " ... and %d more\n", len(entries)-limit) } return b.String() } // FetchRelayList gets the kind-10002 (NIP-65) relay list for a pubkey. func (rp *Pool) FetchRelayList(ctx context.Context, pubkey string, timeout time.Duration) []string { relays := rp.fetchRelayListFrom(ctx, rp.primary, pubkey, timeout) if len(relays) == 0 { for _, url := range rp.topN(5) { relays = rp.fetchRelayListFrom(ctx, url, pubkey, timeout) if len(relays) > 0 { break } } } for _, r := range relays { rp.Add(r) } return relays } func (rp *Pool) topN(n int) []string { ranked := rp.Ranked() if len(ranked) > n { ranked = ranked[:n] } return ranked } // FetchEventsMultiRelay tries to fetch kind-1 events for a pubkey, // starting with the primary relay, then the pubkey's own relay list, // then popular relays from the pool. func (rp *Pool) FetchEventsMultiRelay(ctx context.Context, pubkey string, limit int, minContent int, timeout time.Duration) ([]*nostr.Event, string) { // 1. Try primary relay. events, rateLimited := rp.fetchEventsFrom(ctx, rp.primary, pubkey, limit, timeout) filtered := filterByContentLen(events, minContent) if len(filtered) > 0 { rp.RecordHit(rp.primary) return filtered, rp.primary } if !rateLimited { rp.RecordMiss(rp.primary) } // 2. Get this pubkey's relay list and try those. userRelays := rp.FetchRelayList(ctx, pubkey, timeout) for _, url := range userRelays { if url == rp.primary { continue } events, rateLimited = rp.fetchEventsFrom(ctx, url, pubkey, limit, timeout) filtered = filterByContentLen(events, minContent) if len(filtered) > 0 { rp.RecordHit(url) return filtered, url } if !rateLimited { rp.RecordMiss(url) } } // 3. Fall back to popular relays (max 3 attempts). fallbackTries := 0 for _, url := range rp.Ranked() { if ctx.Err() != nil || fallbackTries >= 3 { break } if containsStr(userRelays, url) || url == rp.primary { continue } fallbackTries++ events, rateLimited = rp.fetchEventsFrom(ctx, url, pubkey, limit, timeout) filtered = filterByContentLen(events, minContent) if len(filtered) > 0 { rp.RecordHit(url) return filtered, url } if !rateLimited { rp.RecordMiss(url) } } return nil, "" } // WaitForRelay blocks until the rate limit interval has elapsed for a relay. func (rp *Pool) WaitForRelay(ctx context.Context, url string) error { url = normalizeRelayURL(url) rp.mu.Lock() ri, ok := rp.relays[url] if !ok { rp.mu.Unlock() return nil } interval := ri.MinInterval last := ri.LastRequest rp.mu.Unlock() if interval > 0 && !last.IsZero() { wait := time.Until(last.Add(interval)) if wait > 0 { select { case <-time.After(wait): case <-ctx.Done(): return ctx.Err() } } } rp.mu.Lock() if ri, ok := rp.relays[url]; ok { ri.LastRequest = time.Now() } rp.mu.Unlock() return nil } // RecordRateLimit marks that a relay rate-limited us. Starts at 2s, // doubles each time, caps at 60s. func (rp *Pool) RecordRateLimit(url string) { url = normalizeRelayURL(url) rp.mu.Lock() defer rp.mu.Unlock() ri, ok := rp.relays[url] if !ok { return } ri.RateLimited++ if ri.MinInterval == 0 { ri.MinInterval = 2 * time.Second } else { ri.MinInterval *= 2 if ri.MinInterval > 60*time.Second { ri.MinInterval = 60 * time.Second } } log.Printf(" rate limited by %s, backing off to %s", url, ri.MinInterval) } func isRateLimitError(err error) bool { if err == nil { return false } s := strings.ToLower(err.Error()) return strings.Contains(s, "rate") || strings.Contains(s, "429") || strings.Contains(s, "too many") || strings.Contains(s, "slow down") } func isRateLimitNotice(notice string) bool { s := strings.ToLower(notice) return strings.Contains(s, "rate") || strings.Contains(s, "too many") || strings.Contains(s, "slow down") || strings.Contains(s, "throttl") } // --- low-level fetch --- func (rp *Pool) fetchEventsFrom(ctx context.Context, relayURL, pubkey string, limit int, timeout time.Duration) ([]*nostr.Event, bool) { if err := rp.WaitForRelay(ctx, relayURL); err != nil { return nil, false } connCtx, connCancel := context.WithTimeout(ctx, 5*time.Second) defer connCancel() client, err := nostr.Connect(connCtx, relayURL) if err != nil { if isRateLimitError(err) { rp.RecordRateLimit(relayURL) return nil, true } return nil, false } defer client.Disconnect() listenDone := make(chan error, 1) go func() { listenDone <- client.Listen(ctx) }() if err := client.Subscribe(ctx, "sc-ev", nostr.Filter{ Authors: []string{pubkey}, Kinds: []int{1}, Limit: &limit, }); err != nil { if isRateLimitError(err) { rp.RecordRateLimit(relayURL) return nil, true } return nil, false } var events []*nostr.Event rateLimited := false timer := time.NewTimer(timeout) defer timer.Stop() for { select { case ev := <-client.Events: if ev != nil { events = append(events, ev) if len(events) >= limit { return events, false } } case notice := <-client.Notices: if isRateLimitNotice(notice) { rp.RecordRateLimit(relayURL) rateLimited = true } case <-timer.C: return events, rateLimited case <-listenDone: return events, rateLimited case <-ctx.Done(): return events, rateLimited } } } func (rp *Pool) fetchRelayListFrom(ctx context.Context, relayURL, pubkey string, timeout time.Duration) []string { if err := rp.WaitForRelay(ctx, relayURL); err != nil { return nil } connCtx, connCancel := context.WithTimeout(ctx, 5*time.Second) defer connCancel() client, err := nostr.Connect(connCtx, relayURL) if err != nil { if isRateLimitError(err) { rp.RecordRateLimit(relayURL) } return nil } defer client.Disconnect() listenDone := make(chan error, 1) go func() { listenDone <- client.Listen(ctx) }() fetchLimit := 1 if err := client.Subscribe(ctx, "sc-rl", nostr.Filter{ Authors: []string{pubkey}, Kinds: []int{10002}, Limit: &fetchLimit, }); err != nil { return nil } timer := time.NewTimer(timeout) defer timer.Stop() for { select { case ev := <-client.Events: if ev == nil { continue } var relays []string for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "r" { url := normalizeRelayURL(tag[1]) if url != "" { relays = append(relays, url) } } } return relays case notice := <-client.Notices: if isRateLimitNotice(notice) { rp.RecordRateLimit(relayURL) } case <-timer.C: return nil case <-listenDone: return nil case <-ctx.Done(): return nil } } } // --- helpers --- func normalizeRelayURL(url string) string { url = strings.TrimSpace(url) url = strings.TrimRight(url, "/") if url == "" { return "" } if !strings.HasPrefix(url, "wss://") && !strings.HasPrefix(url, "ws://") { return "" } return url } func filterByContentLen(events []*nostr.Event, minContent int) []*nostr.Event { var filtered []*nostr.Event for _, ev := range events { if len(ev.Content) >= minContent { filtered = append(filtered, ev) } } return filtered } func containsStr(ss []string, s string) bool { for _, v := range ss { if v == s { return true } } return false }