1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-08-26 18:21:01 +00:00

refactor(reader): use time.Duration instead of minutes count

In general, duration is used as time unit representation.

At some places when int is returned, there's no documentation which unit is used.

So just convert to time.Duration ASAP.
This commit is contained in:
gudvinr 2025-08-18 23:10:18 +03:00 committed by Frédéric Guillot
parent 03021af53c
commit ed3bf59356
10 changed files with 144 additions and 104 deletions

View file

@ -7,7 +7,9 @@ import (
"bytes" "bytes"
"os" "os"
"reflect" "reflect"
"slices"
"testing" "testing"
"time"
) )
func TestLogFileDefaultValue(t *testing.T) { func TestLogFileDefaultValue(t *testing.T) {
@ -887,12 +889,22 @@ func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := 30 expected := 30 * time.Minute
result := opts.SchedulerEntryFrequencyMaxInterval() result := opts.SchedulerEntryFrequencyMaxInterval()
if result != expected { if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected) t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
} }
sorted := opts.SortedOptions(false)
i := slices.IndexFunc(sorted, func(opt *option) bool {
return opt.Key == "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL"
})
expectedSerialized := 30
if got := sorted[i].Value; got != expectedSerialized {
t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
}
} }
func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) { func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
@ -922,12 +934,22 @@ func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := 30 expected := 30 * time.Minute
result := opts.SchedulerEntryFrequencyMinInterval() result := opts.SchedulerEntryFrequencyMinInterval()
if result != expected { if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected) t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
} }
sorted := opts.SortedOptions(false)
i := slices.IndexFunc(sorted, func(opt *option) bool {
return opt.Key == "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL"
})
expectedSerialized := 30
if got := sorted[i].Value; got != expectedSerialized {
t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
}
} }
func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) { func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) {
@ -992,12 +1014,22 @@ func TestSchedulerRoundRobin(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := 15 expected := 15 * time.Minute
result := opts.SchedulerRoundRobinMinInterval() result := opts.SchedulerRoundRobinMinInterval()
if result != expected { if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected) t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected)
} }
sorted := opts.SortedOptions(false)
i := slices.IndexFunc(sorted, func(opt *option) bool {
return opt.Key == "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"
})
expectedSerialized := 15
if got := sorted[i].Value; got != expectedSerialized {
t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
}
} }
func TestDefaultSchedulerRoundRobinMaxIntervalValue(t *testing.T) { func TestDefaultSchedulerRoundRobinMaxIntervalValue(t *testing.T) {
@ -1027,12 +1059,22 @@ func TestSchedulerRoundRobinMaxInterval(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := 150 expected := 150 * time.Minute
result := opts.SchedulerRoundRobinMaxInterval() result := opts.SchedulerRoundRobinMaxInterval()
if result != expected { if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected) t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
} }
sorted := opts.SortedOptions(false)
i := slices.IndexFunc(sorted, func(opt *option) bool {
return opt.Key == "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL"
})
expectedSerialized := 150
if got := sorted[i].Value; got != expectedSerialized {
t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
}
} }
func TestPollingParsingErrorLimit(t *testing.T) { func TestPollingParsingErrorLimit(t *testing.T) {

View file

@ -32,11 +32,11 @@ const (
defaultForceRefreshInterval = 30 defaultForceRefreshInterval = 30
defaultBatchSize = 100 defaultBatchSize = 100
defaultPollingScheduler = "round_robin" defaultPollingScheduler = "round_robin"
defaultSchedulerEntryFrequencyMinInterval = 5 defaultSchedulerEntryFrequencyMinInterval = 5 * time.Minute
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60 defaultSchedulerEntryFrequencyMaxInterval = 24 * time.Hour
defaultSchedulerEntryFrequencyFactor = 1 defaultSchedulerEntryFrequencyFactor = 1
defaultSchedulerRoundRobinMinInterval = 60 defaultSchedulerRoundRobinMinInterval = 1 * time.Hour
defaultSchedulerRoundRobinMaxInterval = 1440 defaultSchedulerRoundRobinMaxInterval = 24 * time.Hour
defaultPollingParsingErrorLimit = 3 defaultPollingParsingErrorLimit = 3
defaultRunMigrations = false defaultRunMigrations = false
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable" defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
@ -132,11 +132,11 @@ type options struct {
cleanupRemoveSessionsDays int cleanupRemoveSessionsDays int
forceRefreshInterval int forceRefreshInterval int
batchSize int batchSize int
schedulerEntryFrequencyMinInterval int schedulerEntryFrequencyMinInterval time.Duration
schedulerEntryFrequencyMaxInterval int schedulerEntryFrequencyMaxInterval time.Duration
schedulerEntryFrequencyFactor int schedulerEntryFrequencyFactor int
schedulerRoundRobinMinInterval int schedulerRoundRobinMinInterval time.Duration
schedulerRoundRobinMaxInterval int schedulerRoundRobinMaxInterval time.Duration
pollingFrequency int pollingFrequency int
pollingLimitPerHost int pollingLimitPerHost int
pollingParsingErrorLimit int pollingParsingErrorLimit int
@ -422,13 +422,13 @@ func (o *options) PollingScheduler() string {
return o.pollingScheduler return o.pollingScheduler
} }
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler. // SchedulerEntryFrequencyMaxInterval returns the maximum interval for the entry frequency scheduler.
func (o *options) SchedulerEntryFrequencyMaxInterval() int { func (o *options) SchedulerEntryFrequencyMaxInterval() time.Duration {
return o.schedulerEntryFrequencyMaxInterval return o.schedulerEntryFrequencyMaxInterval
} }
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler. // SchedulerEntryFrequencyMinInterval returns the minimum interval for the entry frequency scheduler.
func (o *options) SchedulerEntryFrequencyMinInterval() int { func (o *options) SchedulerEntryFrequencyMinInterval() time.Duration {
return o.schedulerEntryFrequencyMinInterval return o.schedulerEntryFrequencyMinInterval
} }
@ -437,11 +437,11 @@ func (o *options) SchedulerEntryFrequencyFactor() int {
return o.schedulerEntryFrequencyFactor return o.schedulerEntryFrequencyFactor
} }
func (o *options) SchedulerRoundRobinMinInterval() int { func (o *options) SchedulerRoundRobinMinInterval() time.Duration {
return o.schedulerRoundRobinMinInterval return o.schedulerRoundRobinMinInterval
} }
func (o *options) SchedulerRoundRobinMaxInterval() int { func (o *options) SchedulerRoundRobinMaxInterval() time.Duration {
return o.schedulerRoundRobinMaxInterval return o.schedulerRoundRobinMaxInterval
} }
@ -781,11 +781,11 @@ func (o *options) SortedOptions(redactSecret bool) []*option {
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL, "MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
"ROOT_URL": o.rootURL, "ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations, "RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval, "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": int(o.schedulerEntryFrequencyMaxInterval.Minutes()),
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": o.schedulerEntryFrequencyMinInterval, "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": int(o.schedulerEntryFrequencyMinInterval.Minutes()),
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": o.schedulerEntryFrequencyFactor, "SCHEDULER_ENTRY_FREQUENCY_FACTOR": o.schedulerEntryFrequencyFactor,
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": o.schedulerRoundRobinMinInterval, "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": int(o.schedulerRoundRobinMinInterval.Minutes()),
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": o.schedulerRoundRobinMaxInterval, "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": int(o.schedulerRoundRobinMaxInterval.Minutes()),
"SCHEDULER_SERVICE": o.schedulerService, "SCHEDULER_SERVICE": o.schedulerService,
"WATCHDOG": o.watchdog, "WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize, "WORKER_POOL_SIZE": o.workerPoolSize,

View file

@ -14,6 +14,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// parser handles configuration parsing. // parser handles configuration parsing.
@ -150,15 +151,15 @@ func (p *parser) parseLines(lines []string) (err error) {
case "POLLING_SCHEDULER": case "POLLING_SCHEDULER":
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler)) p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval) p.opts.schedulerEntryFrequencyMaxInterval = parseInterval(value, time.Minute, defaultSchedulerEntryFrequencyMaxInterval)
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval) p.opts.schedulerEntryFrequencyMinInterval = parseInterval(value, time.Minute, defaultSchedulerEntryFrequencyMinInterval)
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR": case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor) p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval) p.opts.schedulerRoundRobinMinInterval = parseInterval(value, time.Minute, defaultSchedulerRoundRobinMinInterval)
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval) p.opts.schedulerRoundRobinMaxInterval = parseInterval(value, time.Minute, defaultSchedulerRoundRobinMaxInterval)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout) p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_MODE": case "MEDIA_PROXY_MODE":

View file

@ -6,7 +6,6 @@ package model // import "miniflux.app/v2/internal/model"
import ( import (
"fmt" "fmt"
"io" "io"
"math"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -69,7 +68,7 @@ type Feed struct {
Entries Entries `json:"entries,omitempty"` Entries Entries `json:"entries,omitempty"`
// Internal attributes (not exposed in the API and not persisted in the database) // Internal attributes (not exposed in the API and not persisted in the database)
TTL int `json:"-"` TTL time.Duration `json:"-"`
IconURL string `json:"-"` IconURL string `json:"-"`
UnreadCount int `json:"-"` UnreadCount int `json:"-"`
ReadCount int `json:"-"` ReadCount int `json:"-"`
@ -119,35 +118,33 @@ func (f *Feed) CheckedNow() {
} }
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration. // ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) int { func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {
// Default to the global config Polling Frequency. // Default to the global config Polling Frequency.
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval() interval := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency { if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 { if weeklyCount <= 0 {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval() interval = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else { } else {
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor()))) interval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval()) interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval()) interval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())
} }
} }
// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined. // Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes { interval = max(interval, refreshDelay)
intervalMinutes = refreshDelayInMinutes
}
// Limit the max interval value for misconfigured feeds. // Limit the max interval value for misconfigured feeds.
switch config.Opts.PollingScheduler() { switch config.Opts.PollingScheduler() {
case SchedulerRoundRobin: case SchedulerRoundRobin:
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerRoundRobinMaxInterval()) interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())
case SchedulerEntryFrequency: case SchedulerEntryFrequency:
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval()) interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
} }
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes)) f.NextCheckAt = time.Now().Add(interval)
return intervalMinutes return interval
} }
// FeedCreationRequest represents the request to create a feed. // FeedCreationRequest represents the request to create a feed.

View file

@ -67,11 +67,11 @@ func TestFeedCheckedNow(t *testing.T) {
} }
} }
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefore time.Time, message string) { func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(targetInterval))) { if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {
t.Errorf(`The next_check_at should be after timeBefore + %s`, message) t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
} }
if feed.NextCheckAt.After(time.Now().Add(time.Minute * time.Duration(targetInterval))) { if feed.NextCheckAt.After(time.Now().Add(targetInterval)) {
t.Errorf(`The next_check_at should be before now + %s`, message) t.Errorf(`The next_check_at should be before now + %s`, message)
} }
} }
@ -188,7 +188,7 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
expectedInterval := minInterval expectedInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval") checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
} }
@ -217,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
targetInterval := maxInterval targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval") checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
} }
@ -246,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
targetInterval := maxInterval targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval") checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
} }
@ -275,7 +275,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
targetInterval := minInterval targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval") checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
} }
@ -301,7 +301,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / factor targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)
checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count") checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count")
} }
@ -326,17 +326,17 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
// Use a very large weekly count to trigger the min interval // Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount weeklyCount := largeWeeklyCount
// TTL is smaller than minInterval. // TTL is smaller than minInterval.
newTTL := minInterval / 2 newTTL := time.Duration(minInterval) * time.Minute / 2
feed.ScheduleNextCheck(weeklyCount, newTTL) feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() { if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`) t.Error(`The next_check_at must be set`)
} }
targetInterval := minInterval targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval") checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(newTTL))) { if feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {
t.Error(`The next_check_at should be after timeBefore + TTL`) t.Error(`The next_check_at should be after timeBefore + TTL`)
} }
} }
@ -362,7 +362,7 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
// Use a very large weekly count to trigger the min interval // Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount weeklyCount := largeWeeklyCount
// TTL is larger than minInterval. // TTL is larger than minInterval.
newTTL := minInterval * 2 newTTL := time.Duration(minInterval) * time.Minute * 2
feed.ScheduleNextCheck(weeklyCount, newTTL) feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() { if feed.NextCheckAt.IsZero() {

View file

@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -54,18 +53,19 @@ func (r *ResponseHandler) ETag() string {
return r.httpResponse.Header.Get("ETag") return r.httpResponse.Header.Get("ETag")
} }
func (r *ResponseHandler) ExpiresInMinutes() int { func (r *ResponseHandler) Expires() time.Duration {
expiresHeaderValue := r.httpResponse.Header.Get("Expires") expiresHeaderValue := r.httpResponse.Header.Get("Expires")
if expiresHeaderValue != "" { if expiresHeaderValue != "" {
t, err := time.Parse(time.RFC1123, expiresHeaderValue) t, err := time.Parse(time.RFC1123, expiresHeaderValue)
if err == nil { if err == nil {
return int(math.Ceil(time.Until(t).Minutes())) // This rounds up to the next minute by rounding down and just adding a minute.
return time.Until(t).Truncate(time.Minute) + time.Minute
} }
} }
return 0 return 0
} }
func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int { func (r *ResponseHandler) CacheControlMaxAge() time.Duration {
cacheControlHeaderValue := r.httpResponse.Header.Get("Cache-Control") cacheControlHeaderValue := r.httpResponse.Header.Get("Cache-Control")
if cacheControlHeaderValue != "" { if cacheControlHeaderValue != "" {
for _, directive := range strings.Split(cacheControlHeaderValue, ",") { for _, directive := range strings.Split(cacheControlHeaderValue, ",") {
@ -73,7 +73,7 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
if strings.HasPrefix(directive, "max-age=") { if strings.HasPrefix(directive, "max-age=") {
maxAge, err := strconv.Atoi(strings.TrimPrefix(directive, "max-age=")) maxAge, err := strconv.Atoi(strings.TrimPrefix(directive, "max-age="))
if err == nil { if err == nil {
return int(math.Ceil(float64(maxAge) / 60)) return time.Duration(maxAge) * time.Second
} }
} }
} }
@ -81,17 +81,17 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
return 0 return 0
} }
func (r *ResponseHandler) ParseRetryDelay() int { func (r *ResponseHandler) ParseRetryDelay() time.Duration {
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After") retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
if retryAfterHeaderValue != "" { if retryAfterHeaderValue != "" {
// First, try to parse as an integer (number of seconds) // First, try to parse as an integer (number of seconds)
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil { if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
return seconds return time.Duration(seconds) * time.Second
} }
// If not an integer, try to parse as an HTTP-date // If not an integer, try to parse as an HTTP-date
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil { if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
return int(time.Until(t).Seconds()) return time.Until(t).Truncate(time.Second)
} }
} }
return 0 return 0

View file

@ -72,7 +72,7 @@ func TestIsModified(t *testing.T) {
func TestRetryDelay(t *testing.T) { func TestRetryDelay(t *testing.T) {
var testCases = map[string]struct { var testCases = map[string]struct {
RetryAfterHeader string RetryAfterHeader string
ExpectedDelay int ExpectedDelay time.Duration
}{ }{
"Empty header": { "Empty header": {
RetryAfterHeader: "", RetryAfterHeader: "",
@ -80,11 +80,11 @@ func TestRetryDelay(t *testing.T) {
}, },
"Integer value": { "Integer value": {
RetryAfterHeader: "42", RetryAfterHeader: "42",
ExpectedDelay: 42, ExpectedDelay: 42 * time.Second,
}, },
"HTTP-date": { "HTTP-date": {
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123), RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
ExpectedDelay: 41, ExpectedDelay: 41 * time.Second,
}, },
} }
for name, tc := range testCases { for name, tc := range testCases {
@ -106,19 +106,19 @@ func TestRetryDelay(t *testing.T) {
func TestExpiresInMinutes(t *testing.T) { func TestExpiresInMinutes(t *testing.T) {
var testCases = map[string]struct { var testCases = map[string]struct {
ExpiresHeader string ExpiresHeader string
ExpectedMinutes int Expected time.Duration
}{ }{
"Empty header": { "Empty header": {
ExpiresHeader: "", ExpiresHeader: "",
ExpectedMinutes: 0, Expected: 0,
}, },
"Valid Expires header": { "Valid Expires header": {
ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123), ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
ExpectedMinutes: 10, Expected: 10 * time.Minute,
}, },
"Invalid Expires header": { "Invalid Expires header": {
ExpiresHeader: "invalid-date", ExpiresHeader: "invalid-date",
ExpectedMinutes: 0, Expected: 0,
}, },
} }
for name, tc := range testCases { for name, tc := range testCases {
@ -130,8 +130,8 @@ func TestExpiresInMinutes(t *testing.T) {
Header: header, Header: header,
}, },
} }
if tc.ExpectedMinutes != rh.ExpiresInMinutes() { if tc.Expected != rh.Expires() {
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.ExpiresInMinutes(), name) t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.Expires(), name)
} }
}) })
} }
@ -140,23 +140,23 @@ func TestExpiresInMinutes(t *testing.T) {
func TestCacheControlMaxAgeInMinutes(t *testing.T) { func TestCacheControlMaxAgeInMinutes(t *testing.T) {
var testCases = map[string]struct { var testCases = map[string]struct {
CacheControlHeader string CacheControlHeader string
ExpectedMinutes int Expected time.Duration
}{ }{
"Empty header": { "Empty header": {
CacheControlHeader: "", CacheControlHeader: "",
ExpectedMinutes: 0, Expected: 0,
}, },
"Valid max-age": { "Valid max-age": {
CacheControlHeader: "max-age=600", CacheControlHeader: "max-age=600",
ExpectedMinutes: 10, Expected: 10 * time.Minute,
}, },
"Invalid max-age": { "Invalid max-age": {
CacheControlHeader: "max-age=invalid", CacheControlHeader: "max-age=invalid",
ExpectedMinutes: 0, Expected: 0,
}, },
"Multiple directives": { "Multiple directives": {
CacheControlHeader: "no-cache, max-age=300", CacheControlHeader: "no-cache, max-age=300",
ExpectedMinutes: 5, Expected: 5 * time.Minute,
}, },
} }
for name, tc := range testCases { for name, tc := range testCases {
@ -168,8 +168,8 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
Header: header, Header: header,
}, },
} }
if tc.ExpectedMinutes != rh.CacheControlMaxAgeInMinutes() { if tc.Expected != rh.CacheControlMaxAge() {
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.CacheControlMaxAgeInMinutes(), name) t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.CacheControlMaxAge(), name)
} }
}) })
} }

View file

@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"log/slog" "log/slog"
"time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
@ -208,7 +209,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
} }
weeklyEntryCount := 0 weeklyEntryCount := 0
refreshDelayInMinutes := 0 var refreshDelay time.Duration
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency { if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
var weeklyCountErr error var weeklyCountErr error
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID) weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
@ -218,7 +219,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
} }
originalFeed.CheckedNow() originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes) originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
requestBuilder := fetcher.NewRequestBuilder() requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password) requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
@ -242,15 +243,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
defer responseHandler.Close() defer responseHandler.Close()
if responseHandler.IsRateLimited() { if responseHandler.IsRateLimited() {
retryDelayInSeconds := responseHandler.ParseRetryDelay() retryDelay := responseHandler.ParseRetryDelay()
refreshDelayInMinutes = retryDelayInSeconds / 60 calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, retryDelay)
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
slog.Warn("Feed is rate limited", slog.Warn("Feed is rate limited",
slog.String("feed_url", originalFeed.FeedURL), slog.String("feed_url", originalFeed.FeedURL),
slog.Int("retry_delay_in_seconds", retryDelayInSeconds), slog.Int("retry_delay_in_seconds", int(retryDelay.Seconds())),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes), slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
slog.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes), slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
slog.Time("new_next_check_at", originalFeed.NextCheckAt), slog.Time("new_next_check_at", originalFeed.NextCheckAt),
) )
} }
@ -316,22 +316,22 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available. // Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.
// Otherwise, we use the default value from the configuration (min interval parameter). // Otherwise, we use the default value from the configuration (min interval parameter).
feedTTLValue := updatedFeed.TTL feedTTLValue := updatedFeed.TTL
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAgeInMinutes() cacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()
expiresValue := responseHandler.ExpiresInMinutes() expiresValue := responseHandler.Expires()
refreshDelayInMinutes = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue) refreshDelay = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
// Set the next check at with updated arguments. // Set the next check at with updated arguments.
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes) calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
slog.Debug("Updated next check date", slog.Debug("Updated next check date",
slog.Int64("user_id", userID), slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID), slog.Int64("feed_id", feedID),
slog.String("feed_url", originalFeed.FeedURL), slog.String("feed_url", originalFeed.FeedURL),
slog.Int("feed_ttl_minutes", feedTTLValue), slog.Int("feed_ttl_minutes", int(feedTTLValue.Minutes())),
slog.Int("cache_control_max_age_in_minutes", cacheControlMaxAgeValue), slog.Int("cache_control_max_age_in_minutes", int(cacheControlMaxAgeValue.Minutes())),
slog.Int("expires_in_minutes", expiresValue), slog.Int("expires_in_minutes", int(expiresValue.Minutes())),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes), slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
slog.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes), slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
slog.Time("new_next_check_at", originalFeed.NextCheckAt), slog.Time("new_next_check_at", originalFeed.NextCheckAt),
) )

View file

@ -55,7 +55,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
// Get TTL if defined. // Get TTL if defined.
if r.rss.Channel.TTL != "" { if r.rss.Channel.TTL != "" {
if ttl, err := strconv.Atoi(r.rss.Channel.TTL); err == nil { if ttl, err := strconv.Atoi(r.rss.Channel.TTL); err == nil {
feed.TTL = ttl feed.TTL = time.Duration(ttl) * time.Minute
} }
} }

View file

@ -2151,7 +2151,7 @@ func TestParseFeedWithTTLField(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if feed.TTL != 60 { if feed.TTL != 60*time.Minute {
t.Errorf("Incorrect TTL, got: %d", feed.TTL) t.Errorf("Incorrect TTL, got: %d", feed.TTL)
} }
} }