Subscribe and post RSS feeds to Bluesky
rss bluesky

refactor: remove min/max delay

+11 -69
+3 -10
config.example.yaml
··· 19 19 # ... or mappings with per-feed HTTP/pacing overrides. 20 20 # Any field from the global "defaults" block can be overridden here. 21 21 - url: "https://old.reddit.com/r/linux/top/.rss?t=week" 22 - # Stagger requests to avoid bursting Reddit's rate limits. 23 - min_delay: "3s" 24 - max_delay: "9s" 22 + # Use a higher base_backoff to space out Reddit requests and handle 429s. 25 23 base_backoff: "30s" 26 24 max_backoff: "10m" 27 25 honor_retry_after: true ··· 32 30 feeds: 33 31 - "https://another-blog.com/feed.xml" 34 32 - url: "https://old.reddit.com/r/selfhosted/top/.rss?t=week" 35 - min_delay: "3s" 36 - max_delay: "9s" 37 33 base_backoff: "30s" 38 34 max_backoff: "10m" 39 35 honor_retry_after: true ··· 71 67 # Honour the Retry-After response header when the server sends it (e.g. Reddit 429). 72 68 honor_retry_after: true 73 69 74 - # Pre-fetch jitter: a random delay in [min_delay, max_delay] added before each 75 - # request. Leave both unset (or 0s) to disable. Prefer setting this per-feed 76 - # rather than globally so only rate-sensitive feeds are affected. 77 - # min_delay: "0s" 78 - # max_delay: "0s" 70 + # base_backoff is also used as the upper bound for random inter-feed jitter, 71 + # so increasing it for rate-sensitive feeds (e.g. Reddit) spaces out requests.
-15
internal/config/config.go
··· 61 61 UserAgent string `yaml:"user_agent,omitempty"` 62 62 Timeout time.Duration `yaml:"timeout,omitempty"` 63 63 64 - // Pre-fetch jitter. 65 - MinDelay time.Duration `yaml:"min_delay,omitempty"` 66 - MaxDelay time.Duration `yaml:"max_delay,omitempty"` 67 - 68 64 // Retry / backoff on transient HTTP errors (5xx, 429 …) 69 65 MaxRetries *int `yaml:"max_retries,omitempty"` 70 66 BaseBackoff time.Duration `yaml:"base_backoff,omitempty"` ··· 84 80 return FeedOptions{ 85 81 UserAgent: coalesce(f.UserAgent, g.UserAgent, "bskyrss/1.0 (+https://pkg.rbrt.fr/bskyrss)"), 86 82 Timeout: coalesce(f.Timeout, g.Timeout, 30*time.Second), 87 - MinDelay: coalesce(f.MinDelay, g.MinDelay, 0), 88 - MaxDelay: coalesce(f.MaxDelay, g.MaxDelay, 0), 89 83 MaxRetries: coalesce(f.MaxRetries, g.MaxRetries, new(3)), 90 84 BaseBackoff: coalesce(f.BaseBackoff, g.BaseBackoff, 2*time.Second), 91 85 MaxBackoff: coalesce(f.MaxBackoff, g.MaxBackoff, 2*time.Minute), ··· 169 163 func validateFeedOptions(prefix string, o FeedOptions) error { 170 164 if o.Timeout < 0 { 171 165 return fmt.Errorf("%s.timeout must be >= 0", prefix) 172 - } 173 - if o.MinDelay < 0 { 174 - return fmt.Errorf("%s.min_delay must be >= 0", prefix) 175 - } 176 - if o.MaxDelay < 0 { 177 - return fmt.Errorf("%s.max_delay must be >= 0", prefix) 178 - } 179 - if o.MinDelay > 0 && o.MaxDelay > 0 && o.MinDelay > o.MaxDelay { 180 - return fmt.Errorf("%s.min_delay must be <= max_delay", prefix) 181 166 } 182 167 if o.BaseBackoff < 0 { 183 168 return fmt.Errorf("%s.base_backoff must be >= 0", prefix)
+4 -30
internal/config/config_test.go
··· 84 84 - "https://plain-url.com/rss" 85 85 - url: "https://mapping-url.com/rss" 86 86 user_agent: "custom-agent/1.0" 87 - min_delay: "2s" 88 - max_delay: "6s" 89 87 base_backoff: "10s" 90 88 max_backoff: "5m" 91 89 honor_retry_after: false ··· 120 118 if feeds[1].Options.UserAgent != "custom-agent/1.0" { 121 119 t.Errorf("Expected UserAgent 'custom-agent/1.0', got '%s'", feeds[1].Options.UserAgent) 122 120 } 123 - if feeds[1].Options.MinDelay != 2*time.Second { 124 - t.Errorf("Expected MinDelay 2s, got %v", feeds[1].Options.MinDelay) 125 - } 126 - if feeds[1].Options.MaxDelay != 6*time.Second { 127 - t.Errorf("Expected MaxDelay 6s, got %v", feeds[1].Options.MaxDelay) 128 - } 129 121 if feeds[1].Options.BaseBackoff != 10*time.Second { 130 122 t.Errorf("Expected BaseBackoff 10s, got %v", feeds[1].Options.BaseBackoff) 131 123 } ··· 152 144 153 145 defaults: 154 146 user_agent: "global-agent/1.0" 155 - min_delay: "1s" 156 - max_delay: "4s" 147 + base_backoff: "10s" 157 148 ` 158 149 159 150 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { ··· 170 161 if plain.UserAgent != "global-agent/1.0" { 171 162 t.Errorf("Expected global UserAgent for plain feed, got '%s'", plain.UserAgent) 172 163 } 173 - if plain.MinDelay != 1*time.Second { 174 - t.Errorf("Expected MinDelay 1s from global defaults, got %v", plain.MinDelay) 175 - } 176 164 // Timeout should fall back to hard-coded default 177 165 if plain.Timeout != 30*time.Second { 178 166 t.Errorf("Expected default Timeout 30s, got %v", plain.Timeout) ··· 183 171 if overridden.UserAgent != "per-feed-agent/1.0" { 184 172 t.Errorf("Expected per-feed UserAgent, got '%s'", overridden.UserAgent) 185 173 } 186 - // Global delay still inherited 187 - if overridden.MinDelay != 1*time.Second { 188 - t.Errorf("Expected MinDelay 1s inherited from global, got %v", overridden.MinDelay) 174 + // BaseBackoff should be inherited from global defaults 175 + if overridden.BaseBackoff != 10*time.Second { 176 + t.Errorf("Expected BaseBackoff 10s inherited from global, got %v", overridden.BaseBackoff) 189 177 } 190 178 } 191 179 ··· 298 286 password: "password1" 299 287 feeds: 300 288 - url: "" 301 - `, 302 - wantErr: true, 303 - }, 304 - { 305 - name: "defaults min_delay > max_delay", 306 - content: ` 307 - accounts: 308 - - handle: "user1.bsky.social" 309 - password: "password1" 310 - feeds: 311 - - "https://feed1.com/rss" 312 - defaults: 313 - min_delay: "10s" 314 - max_delay: "5s" 315 289 `, 316 290 wantErr: true, 317 291 },
-12
internal/rss/feed.go
··· 63 63 // FetchLatestItems fetches items from feedURL using opts. 64 64 // opts is expected to be fully resolved (via config.Config.Resolved). 65 65 func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string, opts config.FeedOptions) ([]*FeedItem, error) { 66 - if opts.MaxDelay > 0 { 67 - lo := opts.MinDelay 68 - if lo > opts.MaxDelay { 69 - lo = 0 70 - } 71 - select { 72 - case <-ctx.Done(): 73 - return nil, fmt.Errorf("context cancelled before fetch: %w", ctx.Err()) 74 - case <-time.After(lo + randomDuration(opts.MaxDelay-lo)): 75 - } 76 - } 77 - 78 66 transport := &capturingTransport{inner: http.DefaultTransport, userAgent: opts.UserAgent} 79 67 parser := gofeed.NewParser() 80 68 parser.Client = &http.Client{Timeout: opts.Timeout, Transport: transport}
+4 -2
main.go
··· 5 5 "flag" 6 6 "fmt" 7 7 "log" 8 + "math/rand" 8 9 "net/url" 9 10 "os" 10 11 "os/signal" ··· 168 169 } 169 170 if i < len(m.account.Feeds)-1 { 170 171 opts := m.cfg.Resolved(feed) 171 - if opts.MinDelay > 0 { 172 + if opts.BaseBackoff > 0 { 173 + delay := time.Duration(rand.Int63n(int64(opts.BaseBackoff) + 1)) 172 174 select { 173 175 case <-ctx.Done(): 174 176 return nil 175 - case <-time.After(opts.MinDelay): 177 + case <-time.After(delay): 176 178 } 177 179 } 178 180 }