nandi/gleanpublic Fork 0
9d8649d
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Remove feed caching via ETag and Last-Modified headersUnverified

Julien Robert committed 2026-04-24T00:30:03+02:00 Browse files
9d8649d parent: 89856f6
modified docs/specs.md +1 -36
@@ -292,39 +292,7 @@ Glean is first and foremost an RSS reader. It fetches, parses, and stores articl
292292
293293 ### 4.1 Feed Fetching
294294
295-A background scheduler polls subscribed feeds on a fixed 5-minute tick. Feeds are fetched at most once per cycle regardless of how many users share them.
296-
297-```
298- ┌─────────────────────────┐
299- │ Feed Scheduler │
300- │ (background goroutine) │
301- └────────┬────────────────┘
302- │ every 5 min
303- ┌────────▼────────────────┐
304- │ Feed Fetcher │
305- │ │
306- │ 1. SELECT feeds where │
307- │ subscriber_count > 0 │
308- │ AND not fetched in │
309- │ last 30 min │
310- │ 2. Dedup in-flight: │
311- │ skip if already │
312- │ being fetched │
313- │ 3. Respect ETag/If-None│
314- │ Match / Last-Modified│
315- │ 4. GET feed URL │
316- │ 5. Parse XML/JSON │
317- │ 6. Upsert articles │
318- │ 7. Update feed metadata│
319- └────────┬────────────────┘
320-
321- ┌──────────────┼──────────────┐
322- │ │ │
323- ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
324- │RSS/XML │ │RSS1/RDF │ │Atom/XML │ │JSON Feed │
325- │Parser │ │Parser │ │Parser │ │Parser │
326- └─────────┘ └───────────┘ └───────────┘ └───────────┘
327-```
295+A background scheduler polls subscribed feeds on a configurable tick. Feeds are fetched at most once per cycle regardless of how many users share them.
328296
329297 ### 4.2 Fetch Schedule
330298
@@ -334,7 +302,6 @@ The scheduler uses a configurable tick interval with in-flight deduplication:
334302 - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible
335303 - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched
336304 - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again
337-- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified)
338305 - **Error tracking**: `error_count` increments on failure, resets to 0 on success. Feeds with high error counts are surfaced as "dead feeds" to the user.
339306
340307 ```sql
@@ -534,8 +501,6 @@ CREATE TABLE feeds (
534501 last_fetched_at DATETIME,
535502 last_error TEXT,
536503 subscriber_count INTEGER NOT NULL DEFAULT 0,
537- etag TEXT,
538- last_modified TEXT,
539504 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
540505 error_count INTEGER NOT NULL DEFAULT 0,
541506 favicon_url TEXT
@@ -292,39 +292,7 @@ Glean is first and foremost an RSS reader. It fetches, parses, and stores articl
292 292
293 ### 4.1 Feed Fetching293 ### 4.1 Feed Fetching
294 294
295-A background scheduler polls subscribed feeds on a fixed 5-minute tick. Feeds are fetched at most once per cycle regardless of how many users share them.295+A background scheduler polls subscribed feeds on a configurable tick. Feeds are fetched at most once per cycle regardless of how many users share them.
296-
297-```
298- ┌─────────────────────────┐
299- │ Feed Scheduler │
300- │ (background goroutine) │
301- └────────┬────────────────┘
302- │ every 5 min
303- ┌────────▼────────────────┐
304- │ Feed Fetcher │
305- │ │
306- │ 1. SELECT feeds where │
307- │ subscriber_count > 0 │
308- │ AND not fetched in │
309- │ last 30 min │
310- │ 2. Dedup in-flight: │
311- │ skip if already │
312- │ being fetched │
313- │ 3. Respect ETag/If-None│
314- │ Match / Last-Modified│
315- │ 4. GET feed URL │
316- │ 5. Parse XML/JSON │
317- │ 6. Upsert articles │
318- │ 7. Update feed metadata│
319- └────────┬────────────────┘
320-
321- ┌──────────────┼──────────────┐
322- │ │ │
323- ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
324- │RSS/XML │ │RSS1/RDF │ │Atom/XML │ │JSON Feed │
325- │Parser │ │Parser │ │Parser │ │Parser │
326- └─────────┘ └───────────┘ └───────────┘ └───────────┘
327-```
328 296
329 ### 4.2 Fetch Schedule297 ### 4.2 Fetch Schedule
330 298
@@ -334,7 +302,6 @@ The scheduler uses a configurable tick interval with in-flight deduplication:
334 - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible302 - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible
335 - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched303 - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched
336 - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again304 - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again
337-- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified)
338 - **Error tracking**: `error_count` increments on failure, resets to 0 on success. Feeds with high error counts are surfaced as "dead feeds" to the user.305 - **Error tracking**: `error_count` increments on failure, resets to 0 on success. Feeds with high error counts are surfaced as "dead feeds" to the user.
339 306
340 ```sql307 ```sql
@@ -534,8 +501,6 @@ CREATE TABLE feeds (
534 last_fetched_at DATETIME,501 last_fetched_at DATETIME,
535 last_error TEXT,502 last_error TEXT,
536 subscriber_count INTEGER NOT NULL DEFAULT 0,503 subscriber_count INTEGER NOT NULL DEFAULT 0,
537- etag TEXT,
538- last_modified TEXT,
539 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,504 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
540 error_count INTEGER NOT NULL DEFAULT 0,505 error_count INTEGER NOT NULL DEFAULT 0,
541 favicon_url TEXT506 favicon_url TEXT
modified internal/atproto/stream_handler.go +1 -2
@@ -75,7 +75,7 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
7575 return err
7676
7777 case actionDelete:
78- parsed, ok := ParseRecordURI(event.URI)
78+ _, ok := ParseRecordURI(event.URI)
7979 if !ok {
8080 return nil
8181 }
@@ -83,7 +83,6 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
8383 if err == nil && sub != nil {
8484 return h.articles.DeleteSubscription(ctx, event.DID, sub.FeedURL)
8585 }
86- _ = parsed
8786 }
8887 return nil
8988 }
@@ -75,7 +75,7 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
75 return err75 return err
76 76
77 case actionDelete:77 case actionDelete:
78- parsed, ok := ParseRecordURI(event.URI)78+ _, ok := ParseRecordURI(event.URI)
79 if !ok {79 if !ok {
80 return nil80 return nil
81 }81 }
@@ -83,7 +83,6 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
83 if err == nil && sub != nil {83 if err == nil && sub != nil {
84 return h.articles.DeleteSubscription(ctx, event.DID, sub.FeedURL)84 return h.articles.DeleteSubscription(ctx, event.DID, sub.FeedURL)
85 }85 }
86- _ = parsed
87 }86 }
88 return nil87 return nil
89 }88 }
modified internal/db/db.go +0 -2
@@ -173,8 +173,6 @@ var articlesSchema = []string{
173173 last_fetched_at DATETIME,
174174 last_error TEXT,
175175 subscriber_count INTEGER NOT NULL DEFAULT 0,
176- etag TEXT,
177- last_modified TEXT,
178176 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
179177 error_count INTEGER NOT NULL DEFAULT 0,
180178 favicon_url TEXT
@@ -173,8 +173,6 @@ var articlesSchema = []string{
173 last_fetched_at DATETIME,173 last_fetched_at DATETIME,
174 last_error TEXT,174 last_error TEXT,
175 subscriber_count INTEGER NOT NULL DEFAULT 0,175 subscriber_count INTEGER NOT NULL DEFAULT 0,
176- etag TEXT,
177- last_modified TEXT,
178 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,176 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
179 error_count INTEGER NOT NULL DEFAULT 0,177 error_count INTEGER NOT NULL DEFAULT 0,
180 favicon_url TEXT178 favicon_url TEXT
modified internal/db/feed.go +11 -18
@@ -21,8 +21,6 @@ type Feed struct {
2121 LastFetchedAt sql.NullTime
2222 LastError sql.NullString
2323 SubscriberCount int
24- Etag sql.NullString
25- LastModified sql.NullString
2624 ConsecutiveEmptyFetches int
2725 ErrorCount int
2826 FaviconURL sql.NullString
@@ -30,14 +28,12 @@ type Feed struct {
3028
3129 func (f *Feed) ToFeed() *feed.Feed {
3230 return &feed.Feed{
33- URL: f.FeedURL,
34- Title: f.Title.String,
35- SiteURL: f.SiteURL.String,
36- Description: f.Description.String,
37- Type: f.FeedType.String,
38- FaviconURL: f.FaviconURL.String,
39- ETag: f.Etag.String,
40- LastModified: f.LastModified.String,
31+ URL: f.FeedURL,
32+ Title: f.Title.String,
33+ SiteURL: f.SiteURL.String,
34+ Description: f.Description.String,
35+ Type: f.FeedType.String,
36+ FaviconURL: f.FaviconURL.String,
4137 }
4238 }
4339
@@ -54,11 +50,10 @@ type Subscription struct {
5450 FaviconURL sql.NullString
5551 }
5652
57-
5853 func scanFeed(scanner interface{ Scan(...any) error }) (*Feed, error) {
5954 f := &Feed{}
6055 if err := scanner.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
61- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
56+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount,
6257 &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
6358 return nil, err
6459 }
@@ -95,16 +90,14 @@ func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Durat
9590 return feeds, rows.Err()
9691 }
9792
98-func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error {
93+func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL string) error {
9994 _, err := s.db.ExecContext(ctx, `
10095 UPDATE articles.feeds SET
101- etag = ?,
102- last_modified = ?,
10396 error_count = 0,
10497 last_error = '',
10598 last_fetched_at = CURRENT_TIMESTAMP
10699 WHERE feed_url = ?
107- `, etag, lastModified, feedURL)
100+ `, feedURL)
108101 return err
109102 }
110103
@@ -226,11 +219,11 @@ func (s *ArticleStore) GetSubscription(ctx context.Context, userDID, feedURL str
226219 sub := &Subscription{}
227220 err := s.db.QueryRowContext(ctx, `
228221 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
229- s.uri, s.cid
222+ s.uri, s.cid, f.favicon_url
230223 FROM articles.subscriptions s
231224 LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url
232225 WHERE s.user_did = ? AND s.feed_url = ?
233- `, userDID, feedURL).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID)
226+ `, userDID, feedURL).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID, &sub.FaviconURL)
234227 if err != nil {
235228 return nil, err
236229 }
@@ -21,8 +21,6 @@ type Feed struct {
21 LastFetchedAt sql.NullTime21 LastFetchedAt sql.NullTime
22 LastError sql.NullString22 LastError sql.NullString
23 SubscriberCount int23 SubscriberCount int
24- Etag sql.NullString
25- LastModified sql.NullString
26 ConsecutiveEmptyFetches int24 ConsecutiveEmptyFetches int
27 ErrorCount int25 ErrorCount int
28 FaviconURL sql.NullString26 FaviconURL sql.NullString
@@ -30,14 +28,12 @@ type Feed struct {
30 28
31 func (f *Feed) ToFeed() *feed.Feed {29 func (f *Feed) ToFeed() *feed.Feed {
32 return &feed.Feed{30 return &feed.Feed{
33- URL: f.FeedURL,31+ URL: f.FeedURL,
34- Title: f.Title.String,32+ Title: f.Title.String,
35- SiteURL: f.SiteURL.String,33+ SiteURL: f.SiteURL.String,
36- Description: f.Description.String,34+ Description: f.Description.String,
37- Type: f.FeedType.String,35+ Type: f.FeedType.String,
38- FaviconURL: f.FaviconURL.String,36+ FaviconURL: f.FaviconURL.String,
39- ETag: f.Etag.String,
40- LastModified: f.LastModified.String,
41 }37 }
42 }38 }
43 39
@@ -54,11 +50,10 @@ type Subscription struct {
54 FaviconURL sql.NullString50 FaviconURL sql.NullString
55 }51 }
56 52
57-
58 func scanFeed(scanner interface{ Scan(...any) error }) (*Feed, error) {53 func scanFeed(scanner interface{ Scan(...any) error }) (*Feed, error) {
59 f := &Feed{}54 f := &Feed{}
60 if err := scanner.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,55 if err := scanner.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
61- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,56+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount,
62 &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {57 &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
63 return nil, err58 return nil, err
64 }59 }
@@ -95,16 +90,14 @@ func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Durat
95 return feeds, rows.Err()90 return feeds, rows.Err()
96 }91 }
97 92
98-func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error {93+func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL string) error {
99 _, err := s.db.ExecContext(ctx, `94 _, err := s.db.ExecContext(ctx, `
100 UPDATE articles.feeds SET95 UPDATE articles.feeds SET
101- etag = ?,
102- last_modified = ?,
103 error_count = 0,96 error_count = 0,
104 last_error = '',97 last_error = '',
105 last_fetched_at = CURRENT_TIMESTAMP98 last_fetched_at = CURRENT_TIMESTAMP
106 WHERE feed_url = ?99 WHERE feed_url = ?
107- `, etag, lastModified, feedURL)100+ `, feedURL)
108 return err101 return err
109 }102 }
110 103
@@ -226,11 +219,11 @@ func (s *ArticleStore) GetSubscription(ctx context.Context, userDID, feedURL str
226 sub := &Subscription{}219 sub := &Subscription{}
227 err := s.db.QueryRowContext(ctx, `220 err := s.db.QueryRowContext(ctx, `
228 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,221 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
229- s.uri, s.cid222+ s.uri, s.cid, f.favicon_url
230 FROM articles.subscriptions s223 FROM articles.subscriptions s
231 LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url224 LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url
232 WHERE s.user_did = ? AND s.feed_url = ?225 WHERE s.user_did = ? AND s.feed_url = ?
233- `, userDID, feedURL).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID)226+ `, userDID, feedURL).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID, &sub.FaviconURL)
234 if err != nil {227 if err != nil {
235 return nil, err228 return nil, err
236 }229 }
modified internal/db/store.go +2 -2
@@ -31,8 +31,8 @@ func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastEr
3131 return a.store.MarkFeedFetchError(ctx, feedURL, lastError)
3232 }
3333
34-func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error {
35- if err := a.store.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil {
34+func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL string, articles []feed.Article, faviconURL string) error {
35+ if err := a.store.MarkFeedFetched(ctx, feedURL); err != nil {
3636 return err
3737 }
3838 if len(articles) > 0 {
@@ -31,8 +31,8 @@ func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastEr
31 return a.store.MarkFeedFetchError(ctx, feedURL, lastError)31 return a.store.MarkFeedFetchError(ctx, feedURL, lastError)
32 }32 }
33 33
34-func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error {34+func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL string, articles []feed.Article, faviconURL string) error {
35- if err := a.store.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil {35+ if err := a.store.MarkFeedFetched(ctx, feedURL); err != nil {
36 return err36 return err
37 }37 }
38 if len(articles) > 0 {38 if len(articles) > 0 {
modified internal/feed/fetcher.go +19 -41
@@ -37,7 +37,7 @@ func NewFetcher() *Fetcher {
3737 }
3838 }
3939
40-func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {
40+func (f *Fetcher) Fetch(ctx context.Context, feedURL string) (*ParseResult, error) {
4141 var lastResp *http.Response
4242 var lastErr error
4343
@@ -45,81 +45,59 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
4545 if attempt > 0 {
4646 backoff := retryBackoff(attempt, lastResp)
4747 if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
48- return nil, "", "", err
48+ return nil, err
4949 }
5050 }
5151
52- result, newEtag, newLastModified, resp, err := f.executeRequest(ctx, feedURL, etag, lastModified)
52+ result, resp, err := f.executeRequest(ctx, feedURL)
5353 lastResp = resp
5454 if err == nil {
55- return result, newEtag, newLastModified, nil
55+ return result, nil
5656 }
5757
5858 if resp != nil && !httpclient.IsRetryable(resp.StatusCode) {
59- return nil, "", "", err
59+ return nil, err
6060 }
6161
6262 lastErr = err
6363 }
6464
65- return nil, "", "", lastErr
65+ return nil, lastErr
6666 }
6767
68-func (f *Fetcher) executeRequest(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, *http.Response, error) {
68+func (f *Fetcher) executeRequest(ctx context.Context, feedURL string) (*ParseResult, *http.Response, error) {
6969 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
7070 if err != nil {
71- return nil, "", "", nil, fmt.Errorf("creating request: %w", err)
71+ return nil, nil, fmt.Errorf("creating request: %w", err)
7272 }
7373
7474 httpclient.SetDefaultHeaders(req)
7575 req.Header.Set("Accept", httpclient.AcceptFeed)
7676
77- if etag != "" {
78- req.Header.Set("If-None-Match", etag)
79- }
80- if lastModified != "" {
81- req.Header.Set("If-Modified-Since", lastModified)
82- }
83-
8477 resp, err := f.httpClient.Do(req)
8578 if err != nil {
86- return nil, "", "", nil, fmt.Errorf("fetching feed: %w", err)
79+ return nil, nil, fmt.Errorf("fetching feed: %w", err)
8780 }
8881 defer resp.Body.Close()
8982
90- if resp.StatusCode == http.StatusNotModified {
91- newEtag := resp.Header.Get("ETag")
92- if newEtag == "" {
93- newEtag = etag
94- }
95- newLastModified := resp.Header.Get("Last-Modified")
96- if newLastModified == "" {
97- newLastModified = lastModified
98- }
99- return nil, newEtag, newLastModified, resp, nil
100- }
101-
10283 if resp.StatusCode == http.StatusTooManyRequests {
103- return nil, "", "", resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))
84+ return nil, resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))
10485 }
10586
10687 if resp.StatusCode >= 500 {
107- return nil, "", "", resp, fmt.Errorf("server error: %d", resp.StatusCode)
88+ return nil, resp, fmt.Errorf("server error: %d", resp.StatusCode)
10889 }
10990
11091 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
111- return nil, "", "", resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)
92+ return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)
11293 }
11394
114- newEtag := resp.Header.Get("ETag")
115- newLastModified := resp.Header.Get("Last-Modified")
116-
11795 result, err := Parse(resp.Body, feedURL)
11896 if err != nil {
119- return nil, "", "", nil, fmt.Errorf("parsing feed: %w", err)
97+ return nil, nil, fmt.Errorf("parsing feed: %w", err)
12098 }
12199
122- return result, newEtag, newLastModified, resp, nil
100+ return result, resp, nil
123101 }
124102
125103 func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
@@ -135,7 +113,7 @@ func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
135113
136114 type FeedStore interface {
137115 GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error)
138- StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []Article, faviconURL string) error
116+ StoreFetchResult(ctx context.Context, feedURL string, articles []Article, faviconURL string) error
139117 RecordFetchError(ctx context.Context, feedURL, lastError string) error
140118 }
141119
@@ -165,7 +143,7 @@ func NewScheduler(store FeedStore, logger *slog.Logger, tickInterval, staleInter
165143
166144 func (s *Scheduler) Run(ctx context.Context) error {
167145 s.logger.Info("starting initial feed refresh")
168- s.fetchAll(ctx, 0)
146+ s.fetchAll(ctx, s.staleInterval)
169147
170148 ticker := time.NewTicker(s.tickInterval)
171149 defer ticker.Stop()
@@ -215,7 +193,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
215193 }()
216194
217195 start := time.Now()
218- result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified)
196+ result, err := s.fetcher.Fetch(ctx, feed.URL)
219197 metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds())
220198 metrics.FeedsFetched.Inc()
221199 metrics.FeedsFetchedLast.Set(float64(time.Now().Unix()))
@@ -227,7 +205,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
227205
228206 if result == nil {
229207 s.logger.Info("fetched articles", "feed", feed.URL, "count", 0)
230- if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, nil, ""); err != nil {
208+ if err := s.store.StoreFetchResult(ctx, feed.URL, nil, ""); err != nil {
231209 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)
232210 }
233211 return
@@ -238,7 +216,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
238216 faviconURL = ResolveFavicon(context.Background(), feed.URL, feed.SiteURL)
239217 }
240218
241- if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, result.Articles, faviconURL); err != nil {
219+ if err := s.store.StoreFetchResult(ctx, feed.URL, result.Articles, faviconURL); err != nil {
242220 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)
243221 } else {
244222 articleCount := len(result.Articles)
@@ -37,7 +37,7 @@ func NewFetcher() *Fetcher {
37 }37 }
38 }38 }
39 39
40-func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {40+func (f *Fetcher) Fetch(ctx context.Context, feedURL string) (*ParseResult, error) {
41 var lastResp *http.Response41 var lastResp *http.Response
42 var lastErr error42 var lastErr error
43 43
@@ -45,81 +45,59 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
45 if attempt > 0 {45 if attempt > 0 {
46 backoff := retryBackoff(attempt, lastResp)46 backoff := retryBackoff(attempt, lastResp)
47 if err := httpclient.SleepWithContext(ctx, backoff); err != nil {47 if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
48- return nil, "", "", err48+ return nil, err
49 }49 }
50 }50 }
51 51
52- result, newEtag, newLastModified, resp, err := f.executeRequest(ctx, feedURL, etag, lastModified)52+ result, resp, err := f.executeRequest(ctx, feedURL)
53 lastResp = resp53 lastResp = resp
54 if err == nil {54 if err == nil {
55- return result, newEtag, newLastModified, nil55+ return result, nil
56 }56 }
57 57
58 if resp != nil && !httpclient.IsRetryable(resp.StatusCode) {58 if resp != nil && !httpclient.IsRetryable(resp.StatusCode) {
59- return nil, "", "", err59+ return nil, err
60 }60 }
61 61
62 lastErr = err62 lastErr = err
63 }63 }
64 64
65- return nil, "", "", lastErr65+ return nil, lastErr
66 }66 }
67 67
68-func (f *Fetcher) executeRequest(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, *http.Response, error) {68+func (f *Fetcher) executeRequest(ctx context.Context, feedURL string) (*ParseResult, *http.Response, error) {
69 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)69 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
70 if err != nil {70 if err != nil {
71- return nil, "", "", nil, fmt.Errorf("creating request: %w", err)71+ return nil, nil, fmt.Errorf("creating request: %w", err)
72 }72 }
73 73
74 httpclient.SetDefaultHeaders(req)74 httpclient.SetDefaultHeaders(req)
75 req.Header.Set("Accept", httpclient.AcceptFeed)75 req.Header.Set("Accept", httpclient.AcceptFeed)
76 76
77- if etag != "" {
78- req.Header.Set("If-None-Match", etag)
79- }
80- if lastModified != "" {
81- req.Header.Set("If-Modified-Since", lastModified)
82- }
83-
84 resp, err := f.httpClient.Do(req)77 resp, err := f.httpClient.Do(req)
85 if err != nil {78 if err != nil {
86- return nil, "", "", nil, fmt.Errorf("fetching feed: %w", err)79+ return nil, nil, fmt.Errorf("fetching feed: %w", err)
87 }80 }
88 defer resp.Body.Close()81 defer resp.Body.Close()
89 82
90- if resp.StatusCode == http.StatusNotModified {
91- newEtag := resp.Header.Get("ETag")
92- if newEtag == "" {
93- newEtag = etag
94- }
95- newLastModified := resp.Header.Get("Last-Modified")
96- if newLastModified == "" {
97- newLastModified = lastModified
98- }
99- return nil, newEtag, newLastModified, resp, nil
100- }
101-
102 if resp.StatusCode == http.StatusTooManyRequests {83 if resp.StatusCode == http.StatusTooManyRequests {
103- return nil, "", "", resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))84+ return nil, resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))
104 }85 }
105 86
106 if resp.StatusCode >= 500 {87 if resp.StatusCode >= 500 {
107- return nil, "", "", resp, fmt.Errorf("server error: %d", resp.StatusCode)88+ return nil, resp, fmt.Errorf("server error: %d", resp.StatusCode)
108 }89 }
109 90
110 if resp.StatusCode < 200 || resp.StatusCode >= 300 {91 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
111- return nil, "", "", resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)92+ return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)
112 }93 }
113 94
114- newEtag := resp.Header.Get("ETag")
115- newLastModified := resp.Header.Get("Last-Modified")
116-
117 result, err := Parse(resp.Body, feedURL)95 result, err := Parse(resp.Body, feedURL)
118 if err != nil {96 if err != nil {
119- return nil, "", "", nil, fmt.Errorf("parsing feed: %w", err)97+ return nil, nil, fmt.Errorf("parsing feed: %w", err)
120 }98 }
121 99
122- return result, newEtag, newLastModified, resp, nil100+ return result, resp, nil
123 }101 }
124 102
125 func retryBackoff(attempt int, lastResp *http.Response) time.Duration {103 func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
@@ -135,7 +113,7 @@ func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
135 113
136 type FeedStore interface {114 type FeedStore interface {
137 GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error)115 GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error)
138- StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []Article, faviconURL string) error116+ StoreFetchResult(ctx context.Context, feedURL string, articles []Article, faviconURL string) error
139 RecordFetchError(ctx context.Context, feedURL, lastError string) error117 RecordFetchError(ctx context.Context, feedURL, lastError string) error
140 }118 }
141 119
@@ -165,7 +143,7 @@ func NewScheduler(store FeedStore, logger *slog.Logger, tickInterval, staleInter
165 143
166 func (s *Scheduler) Run(ctx context.Context) error {144 func (s *Scheduler) Run(ctx context.Context) error {
167 s.logger.Info("starting initial feed refresh")145 s.logger.Info("starting initial feed refresh")
168- s.fetchAll(ctx, 0)146+ s.fetchAll(ctx, s.staleInterval)
169 147
170 ticker := time.NewTicker(s.tickInterval)148 ticker := time.NewTicker(s.tickInterval)
171 defer ticker.Stop()149 defer ticker.Stop()
@@ -215,7 +193,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
215 }()193 }()
216 194
217 start := time.Now()195 start := time.Now()
218- result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified)196+ result, err := s.fetcher.Fetch(ctx, feed.URL)
219 metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds())197 metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds())
220 metrics.FeedsFetched.Inc()198 metrics.FeedsFetched.Inc()
221 metrics.FeedsFetchedLast.Set(float64(time.Now().Unix()))199 metrics.FeedsFetchedLast.Set(float64(time.Now().Unix()))
@@ -227,7 +205,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
227 205
228 if result == nil {206 if result == nil {
229 s.logger.Info("fetched articles", "feed", feed.URL, "count", 0)207 s.logger.Info("fetched articles", "feed", feed.URL, "count", 0)
230- if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, nil, ""); err != nil {208+ if err := s.store.StoreFetchResult(ctx, feed.URL, nil, ""); err != nil {
231 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)209 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)
232 }210 }
233 return211 return
@@ -238,7 +216,7 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
238 faviconURL = ResolveFavicon(context.Background(), feed.URL, feed.SiteURL)216 faviconURL = ResolveFavicon(context.Background(), feed.URL, feed.SiteURL)
239 }217 }
240 218
241- if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, result.Articles, faviconURL); err != nil {219+ if err := s.store.StoreFetchResult(ctx, feed.URL, result.Articles, faviconURL); err != nil {
242 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)220 s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL)
243 } else {221 } else {
244 articleCount := len(result.Articles)222 articleCount := len(result.Articles)
modified internal/feed/parser.go +6 -8
@@ -13,14 +13,12 @@ import (
1313 )
1414
1515 type Feed struct {
16- URL string
17- Title string
18- SiteURL string
19- Description string
20- Type string
21- FaviconURL string
22- ETag string
23- LastModified string
16+ URL string
17+ Title string
18+ SiteURL string
19+ Description string
20+ Type string
21+ FaviconURL string
2422 }
2523
2624 type Article struct {
@@ -13,14 +13,12 @@ import (
13 )13 )
14 14
15 type Feed struct {15 type Feed struct {
16- URL string16+ URL string
17- Title string17+ Title string
18- SiteURL string18+ SiteURL string
19- Description string19+ Description string
20- Type string20+ Type string
21- FaviconURL string21+ FaviconURL string
22- ETag string
23- LastModified string
24 }22 }
25 23
26 type Article struct {24 type Article struct {
modified internal/server/feeds_handler.go +2 -2
@@ -93,7 +93,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
9393 return
9494 }
9595
96- result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "")
96+ result, err := s.fetcher.Fetch(r.Context(), feedURL)
9797 if err != nil {
9898 result, feedURL, err = s.discoverFeed(r.Context(), feedURL)
9999 }
@@ -463,7 +463,7 @@ func (s *Server) discoverFeed(ctx context.Context, feedURL string) (*feed.ParseR
463463 }
464464
465465 for _, candidate := range discovered.FeedURLs {
466- result, _, _, fetchErr := s.fetcher.Fetch(ctx, candidate, "", "")
466+ result, fetchErr := s.fetcher.Fetch(ctx, candidate)
467467 if fetchErr == nil && result != nil {
468468 return result, candidate, nil
469469 }
@@ -93,7 +93,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
93 return93 return
94 }94 }
95 95
96- result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "")96+ result, err := s.fetcher.Fetch(r.Context(), feedURL)
97 if err != nil {97 if err != nil {
98 result, feedURL, err = s.discoverFeed(r.Context(), feedURL)98 result, feedURL, err = s.discoverFeed(r.Context(), feedURL)
99 }99 }
@@ -463,7 +463,7 @@ func (s *Server) discoverFeed(ctx context.Context, feedURL string) (*feed.ParseR
463 }463 }
464 464
465 for _, candidate := range discovered.FeedURLs {465 for _, candidate := range discovered.FeedURLs {
466- result, _, _, fetchErr := s.fetcher.Fetch(ctx, candidate, "", "")466+ result, fetchErr := s.fetcher.Fetch(ctx, candidate)
467 if fetchErr == nil && result != nil {467 if fetchErr == nil && result != nil {
468 return result, candidate, nil468 return result, candidate, nil
469 }469 }