nandi/gleanpublic Fork 0
cd41abf
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.

Add retry logic and centralized HTTP client configurationUnverified

Julien Robert committed 2026-04-23T13:36:57+02:00 Browse files
cd41abf parent: a9c7fcb
modified internal/feed/discover.go +9 -1
@@ -8,6 +8,8 @@ import (
88 "regexp"
99 "strings"
1010 "time"
11+
12+ "pkg.rbrt.fr/glean/internal/httpclient"
1113 )
1214
1315 type imageContentTypePrefixes []string
@@ -37,7 +39,10 @@ var (
3739 baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`)
3840
3941 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}
40- discoverClient = &http.Client{Timeout: 15 * time.Second}
42+ discoverClient = &http.Client{
43+ Timeout: 15 * time.Second,
44+ Transport: httpclient.NewTransport(),
45+ }
4146 )
4247
4348 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {
@@ -129,6 +134,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string {
129134 if err != nil {
130135 return
131136 }
137+ httpclient.SetDefaultHeaders(req)
132138 resp, err := discoverClient.Do(req)
133139 if err != nil {
134140 return
@@ -158,6 +164,7 @@ func checkContentType(ctx context.Context, url string) bool {
158164 if err != nil {
159165 return false
160166 }
167+ httpclient.SetDefaultHeaders(req)
161168 resp, err := discoverClient.Do(req)
162169 if err != nil {
163170 return false
@@ -179,6 +186,7 @@ func fetchHTML(ctx context.Context, siteURL string) (*url.URL, string) {
179186 if err != nil {
180187 return nil, ""
181188 }
189+ httpclient.SetDefaultHeaders(req)
182190 req.Header.Set("Accept", "text/html")
183191
184192 resp, err := discoverClient.Do(req)
@@ -8,6 +8,8 @@ import (
8 "regexp"8 "regexp"
9 "strings"9 "strings"
10 "time"10 "time"
11+
12+ "pkg.rbrt.fr/glean/internal/httpclient"
11 )13 )
12 14
13 type imageContentTypePrefixes []string15 type imageContentTypePrefixes []string
@@ -37,7 +39,10 @@ var (
37 baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`)39 baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`)
38 40
39 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}41 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}
40- discoverClient = &http.Client{Timeout: 15 * time.Second}42+ discoverClient = &http.Client{
43+ Timeout: 15 * time.Second,
44+ Transport: httpclient.NewTransport(),
45+ }
41 )46 )
42 47
43 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {48 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {
@@ -129,6 +134,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string {
129 if err != nil {134 if err != nil {
130 return135 return
131 }136 }
137+ httpclient.SetDefaultHeaders(req)
132 resp, err := discoverClient.Do(req)138 resp, err := discoverClient.Do(req)
133 if err != nil {139 if err != nil {
134 return140 return
@@ -158,6 +164,7 @@ func checkContentType(ctx context.Context, url string) bool {
158 if err != nil {164 if err != nil {
159 return false165 return false
160 }166 }
167+ httpclient.SetDefaultHeaders(req)
161 resp, err := discoverClient.Do(req)168 resp, err := discoverClient.Do(req)
162 if err != nil {169 if err != nil {
163 return false170 return false
@@ -179,6 +186,7 @@ func fetchHTML(ctx context.Context, siteURL string) (*url.URL, string) {
179 if err != nil {186 if err != nil {
180 return nil, ""187 return nil, ""
181 }188 }
189+ httpclient.SetDefaultHeaders(req)
182 req.Header.Set("Accept", "text/html")190 req.Header.Set("Accept", "text/html")
183 191
184 resp, err := discoverClient.Do(req)192 resp, err := discoverClient.Do(req)
modified internal/feed/fetcher.go +64 -8
@@ -8,9 +8,15 @@ import (
88 "sync"
99 "time"
1010
11+ "pkg.rbrt.fr/glean/internal/httpclient"
1112 "pkg.rbrt.fr/glean/internal/metrics"
1213 )
1314
15+const (
16+ maxRetries = 3
17+ baseRetryDelay = 1 * time.Second
18+)
19+
1420 type Fetcher struct {
1521 httpClient *http.Client
1622 }
@@ -18,7 +24,8 @@ type Fetcher struct {
1824 func NewFetcher() *Fetcher {
1925 return &Fetcher{
2026 httpClient: &http.Client{
21- Timeout: 30 * time.Second,
27+ Timeout: 30 * time.Second,
28+ Transport: httpclient.NewTransport(),
2229 CheckRedirect: func(req *http.Request, via []*http.Request) error {
2330 if len(via) >= 10 {
2431 return fmt.Errorf("too many redirects")
@@ -30,11 +37,42 @@ func NewFetcher() *Fetcher {
3037 }
3138
3239 func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {
40+ var lastResp *http.Response
41+ var lastErr error
42+
43+ for attempt := range maxRetries + 1 {
44+ if attempt > 0 {
45+ backoff := retryBackoff(attempt, lastResp)
46+ if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
47+ return nil, "", "", err
48+ }
49+ }
50+
51+ result, newEtag, newLastModified, resp, err := f.executeRequest(ctx, feedURL, etag, lastModified)
52+ lastResp = resp
53+ if err == nil {
54+ return result, newEtag, newLastModified, nil
55+ }
56+
57+ if resp == nil || !httpclient.IsRetryable(resp.StatusCode) {
58+ return nil, "", "", err
59+ }
60+
61+ lastErr = err
62+ }
63+
64+ return nil, "", "", lastErr
65+}
66+
67+func (f *Fetcher) executeRequest(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, *http.Response, error) {
3368 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
3469 if err != nil {
35- return nil, "", "", fmt.Errorf("creating request: %w", err)
70+ return nil, "", "", nil, fmt.Errorf("creating request: %w", err)
3671 }
3772
73+ httpclient.SetDefaultHeaders(req)
74+ req.Header.Set("Accept", httpclient.AcceptFeed)
75+
3876 if etag != "" {
3977 req.Header.Set("If-None-Match", etag)
4078 }
@@ -44,16 +82,24 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
4482
4583 resp, err := f.httpClient.Do(req)
4684 if err != nil {
47- return nil, "", "", fmt.Errorf("fetching feed: %w", err)
85+ return nil, "", "", nil, fmt.Errorf("fetching feed: %w", err)
4886 }
4987 defer resp.Body.Close()
5088
5189 if resp.StatusCode == http.StatusNotModified {
52- return nil, "", "", nil
90+ return nil, "", "", resp, nil
91+ }
92+
93+ if resp.StatusCode == http.StatusTooManyRequests {
94+ return nil, "", "", resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))
95+ }
96+
97+ if resp.StatusCode >= 500 {
98+ return nil, "", "", resp, fmt.Errorf("server error: %d", resp.StatusCode)
5399 }
54100
55101 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
56- return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
102+ return nil, "", "", resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)
57103 }
58104
59105 newEtag := resp.Header.Get("ETag")
@@ -61,10 +107,21 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
61107
62108 result, err := Parse(resp.Body, feedURL)
63109 if err != nil {
64- return nil, "", "", fmt.Errorf("parsing feed: %w", err)
110+ return nil, "", "", nil, fmt.Errorf("parsing feed: %w", err)
65111 }
66112
67- return result, newEtag, newLastModified, nil
113+ return result, newEtag, newLastModified, resp, nil
114+}
115+
116+func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
117+ if lastResp != nil && lastResp.StatusCode == http.StatusTooManyRequests {
118+ if v := lastResp.Header.Get("Retry-After"); v != "" {
119+ if d := httpclient.ParseRetryAfter(v); d > 0 {
120+ return min(d, 10*time.Second)
121+ }
122+ }
123+ }
124+ return baseRetryDelay * time.Duration(1<<(attempt-1))
68125 }
69126
70127 type FeedStore interface {
@@ -101,7 +158,6 @@ func (s *Scheduler) Run(ctx context.Context) error {
101158 ticker := time.NewTicker(s.tickInterval)
102159 defer ticker.Stop()
103160
104- // fetch all at startup
105161 s.fetchAll(ctx)
106162
107163 for {
@@ -8,9 +8,15 @@ import (
8 "sync"8 "sync"
9 "time"9 "time"
10 10
11+ "pkg.rbrt.fr/glean/internal/httpclient"
11 "pkg.rbrt.fr/glean/internal/metrics"12 "pkg.rbrt.fr/glean/internal/metrics"
12 )13 )
13 14
15+const (
16+ maxRetries = 3
17+ baseRetryDelay = 1 * time.Second
18+)
19+
14 type Fetcher struct {20 type Fetcher struct {
15 httpClient *http.Client21 httpClient *http.Client
16 }22 }
@@ -18,7 +24,8 @@ type Fetcher struct {
18 func NewFetcher() *Fetcher {24 func NewFetcher() *Fetcher {
19 return &Fetcher{25 return &Fetcher{
20 httpClient: &http.Client{26 httpClient: &http.Client{
21- Timeout: 30 * time.Second,27+ Timeout: 30 * time.Second,
28+ Transport: httpclient.NewTransport(),
22 CheckRedirect: func(req *http.Request, via []*http.Request) error {29 CheckRedirect: func(req *http.Request, via []*http.Request) error {
23 if len(via) >= 10 {30 if len(via) >= 10 {
24 return fmt.Errorf("too many redirects")31 return fmt.Errorf("too many redirects")
@@ -30,11 +37,42 @@ func NewFetcher() *Fetcher {
30 }37 }
31 38
32 func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {39 func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {
40+ var lastResp *http.Response
41+ var lastErr error
42+
43+ for attempt := range maxRetries + 1 {
44+ if attempt > 0 {
45+ backoff := retryBackoff(attempt, lastResp)
46+ if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
47+ return nil, "", "", err
48+ }
49+ }
50+
51+ result, newEtag, newLastModified, resp, err := f.executeRequest(ctx, feedURL, etag, lastModified)
52+ lastResp = resp
53+ if err == nil {
54+ return result, newEtag, newLastModified, nil
55+ }
56+
57+ if resp == nil || !httpclient.IsRetryable(resp.StatusCode) {
58+ return nil, "", "", err
59+ }
60+
61+ lastErr = err
62+ }
63+
64+ return nil, "", "", lastErr
65+}
66+
67+func (f *Fetcher) executeRequest(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, *http.Response, error) {
33 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)68 req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
34 if err != nil {69 if err != nil {
35- return nil, "", "", fmt.Errorf("creating request: %w", err)70+ return nil, "", "", nil, fmt.Errorf("creating request: %w", err)
36 }71 }
37 72
73+ httpclient.SetDefaultHeaders(req)
74+ req.Header.Set("Accept", httpclient.AcceptFeed)
75+
38 if etag != "" {76 if etag != "" {
39 req.Header.Set("If-None-Match", etag)77 req.Header.Set("If-None-Match", etag)
40 }78 }
@@ -44,16 +82,24 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
44 82
45 resp, err := f.httpClient.Do(req)83 resp, err := f.httpClient.Do(req)
46 if err != nil {84 if err != nil {
47- return nil, "", "", fmt.Errorf("fetching feed: %w", err)85+ return nil, "", "", nil, fmt.Errorf("fetching feed: %w", err)
48 }86 }
49 defer resp.Body.Close()87 defer resp.Body.Close()
50 88
51 if resp.StatusCode == http.StatusNotModified {89 if resp.StatusCode == http.StatusNotModified {
52- return nil, "", "", nil90+ return nil, "", "", resp, nil
91+ }
92+
93+ if resp.StatusCode == http.StatusTooManyRequests {
94+ return nil, "", "", resp, fmt.Errorf("rate limited (retry-after: %s)", resp.Header.Get("Retry-After"))
95+ }
96+
97+ if resp.StatusCode >= 500 {
98+ return nil, "", "", resp, fmt.Errorf("server error: %d", resp.StatusCode)
53 }99 }
54 100
55 if resp.StatusCode < 200 || resp.StatusCode >= 300 {101 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
56- return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)102+ return nil, "", "", resp, fmt.Errorf("unexpected status: %d", resp.StatusCode)
57 }103 }
58 104
59 newEtag := resp.Header.Get("ETag")105 newEtag := resp.Header.Get("ETag")
@@ -61,10 +107,21 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string)
61 107
62 result, err := Parse(resp.Body, feedURL)108 result, err := Parse(resp.Body, feedURL)
63 if err != nil {109 if err != nil {
64- return nil, "", "", fmt.Errorf("parsing feed: %w", err)110+ return nil, "", "", nil, fmt.Errorf("parsing feed: %w", err)
65 }111 }
66 112
67- return result, newEtag, newLastModified, nil113+ return result, newEtag, newLastModified, resp, nil
114+}
115+
116+func retryBackoff(attempt int, lastResp *http.Response) time.Duration {
117+ if lastResp != nil && lastResp.StatusCode == http.StatusTooManyRequests {
118+ if v := lastResp.Header.Get("Retry-After"); v != "" {
119+ if d := httpclient.ParseRetryAfter(v); d > 0 {
120+ return min(d, 10*time.Second)
121+ }
122+ }
123+ }
124+ return baseRetryDelay * time.Duration(1<<(attempt-1))
68 }125 }
69 126
70 type FeedStore interface {127 type FeedStore interface {
@@ -101,7 +158,6 @@ func (s *Scheduler) Run(ctx context.Context) error {
101 ticker := time.NewTicker(s.tickInterval)158 ticker := time.NewTicker(s.tickInterval)
102 defer ticker.Stop()159 defer ticker.Stop()
103 160
104- // fetch all at startup
105 s.fetchAll(ctx)161 s.fetchAll(ctx)
106 162
107 for {163 for {
added internal/httpclient/httpclient.go +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+package httpclient
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net"
7+ "net/http"
8+ "strconv"
9+ "time"
10+)
11+
12+const (
13+ UserAgent = "Glean/1.0 (RSS Reader)"
14+
15+ AcceptFeed = "application/xml,application/atom+xml,application/rss+xml,application/rdf+xml,application/feed+json,text/html;q=0.9"
16+ AcceptHTML = "text/html,application/xhtml+xml;q=0.9"
17+)
18+
19+func NewTransport() *http.Transport {
20+ return &http.Transport{
21+ DialContext: (&net.Dialer{
22+ Timeout: 10 * time.Second,
23+ KeepAlive: 15 * time.Second,
24+ }).DialContext,
25+ MaxIdleConns: 50,
26+ IdleConnTimeout: 10 * time.Second,
27+ TLSHandshakeTimeout: 10 * time.Second,
28+ ForceAttemptHTTP2: true,
29+ }
30+}
31+
32+func SetDefaultHeaders(req *http.Request) {
33+ req.Header.Set("User-Agent", UserAgent)
34+ req.Header.Set("Accept-Encoding", "br,gzip")
35+ req.Header.Set("Connection", "close")
36+}
37+
38+func ParseRetryAfter(v string) time.Duration {
39+ if d, err := strconv.Atoi(v); err == nil {
40+ return time.Duration(d) * time.Second
41+ }
42+ if t, err := http.ParseTime(v); err == nil {
43+ return time.Until(t)
44+ }
45+ return 0
46+}
47+
48+func SleepWithContext(ctx context.Context, d time.Duration) error {
49+ timer := time.NewTimer(d)
50+ defer timer.Stop()
51+ select {
52+ case <-ctx.Done():
53+ return ctx.Err()
54+ case <-timer.C:
55+ return nil
56+ }
57+}
58+
59+type StatusError struct {
60+ StatusCode int
61+ RetryAfter time.Duration
62+}
63+
64+func (e *StatusError) Error() string {
65+ return fmt.Sprintf("HTTP %d", e.StatusCode)
66+}
67+
68+func IsRetryable(statusCode int) bool {
69+ return statusCode == http.StatusTooManyRequests || statusCode >= 500
70+}
new file mode 100644
@@ -0,0 +1,70 @@
1+package httpclient
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net"
7+ "net/http"
8+ "strconv"
9+ "time"
10+)
11+
12+const (
13+ UserAgent = "Glean/1.0 (RSS Reader)"
14+
15+ AcceptFeed = "application/xml,application/atom+xml,application/rss+xml,application/rdf+xml,application/feed+json,text/html;q=0.9"
16+ AcceptHTML = "text/html,application/xhtml+xml;q=0.9"
17+)
18+
19+func NewTransport() *http.Transport {
20+ return &http.Transport{
21+ DialContext: (&net.Dialer{
22+ Timeout: 10 * time.Second,
23+ KeepAlive: 15 * time.Second,
24+ }).DialContext,
25+ MaxIdleConns: 50,
26+ IdleConnTimeout: 10 * time.Second,
27+ TLSHandshakeTimeout: 10 * time.Second,
28+ ForceAttemptHTTP2: true,
29+ }
30+}
31+
32+func SetDefaultHeaders(req *http.Request) {
33+ req.Header.Set("User-Agent", UserAgent)
34+ req.Header.Set("Accept-Encoding", "br,gzip")
35+ req.Header.Set("Connection", "close")
36+}
37+
38+func ParseRetryAfter(v string) time.Duration {
39+ if d, err := strconv.Atoi(v); err == nil {
40+ return time.Duration(d) * time.Second
41+ }
42+ if t, err := http.ParseTime(v); err == nil {
43+ return time.Until(t)
44+ }
45+ return 0
46+}
47+
48+func SleepWithContext(ctx context.Context, d time.Duration) error {
49+ timer := time.NewTimer(d)
50+ defer timer.Stop()
51+ select {
52+ case <-ctx.Done():
53+ return ctx.Err()
54+ case <-timer.C:
55+ return nil
56+ }
57+}
58+
59+type StatusError struct {
60+ StatusCode int
61+ RetryAfter time.Duration
62+}
63+
64+func (e *StatusError) Error() string {
65+ return fmt.Sprintf("HTTP %d", e.StatusCode)
66+}
67+
68+func IsRetryable(statusCode int) bool {
69+ return statusCode == http.StatusTooManyRequests || statusCode >= 500
70+}
modified internal/scraper/scraper.go +41 -8
@@ -3,6 +3,7 @@ package scraper
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "fmt"
78 "io"
89 "log/slog"
@@ -10,6 +11,8 @@ import (
1011 "strings"
1112 "time"
1213
14+ "pkg.rbrt.fr/glean/internal/httpclient"
15+
1316 "golang.org/x/net/html"
1417 )
1518
@@ -22,7 +25,8 @@ type Scraper struct {
2225 func New(logger *slog.Logger) *Scraper {
2326 return &Scraper{
2427 client: &http.Client{
25- Timeout: 15 * time.Second,
28+ Timeout: 15 * time.Second,
29+ Transport: httpclient.NewTransport(),
2630 CheckRedirect: func(req *http.Request, via []*http.Request) error {
2731 if len(via) >= 10 {
2832 return fmt.Errorf("too many redirects")
@@ -66,11 +70,36 @@ func (s *Scraper) scrapeArchive(ctx context.Context, articleURL string) (string,
6670 }
6771
6872 func (s *Scraper) fetch(ctx context.Context, url string) (io.Reader, error) {
73+ reader, err := s.doFetch(ctx, url)
74+ if err == nil {
75+ return reader, nil
76+ }
77+
78+ var se *httpclient.StatusError
79+ if !errors.As(err, &se) || !httpclient.IsRetryable(se.StatusCode) {
80+ return nil, err
81+ }
82+
83+ backoff := 2 * time.Second
84+ if se.RetryAfter > 0 {
85+ backoff = min(se.RetryAfter, 5*time.Second)
86+ }
87+
88+ if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
89+ return nil, err
90+ }
91+
92+ return s.doFetch(ctx, url)
93+}
94+
95+func (s *Scraper) doFetch(ctx context.Context, url string) (io.Reader, error) {
6996 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
7097 if err != nil {
7198 return nil, err
7299 }
73- req.Header.Set("User-Agent", "Glean/1.0 (RSS Reader)")
100+
101+ httpclient.SetDefaultHeaders(req)
102+ req.Header.Set("Accept", httpclient.AcceptHTML)
74103
75104 resp, err := s.client.Do(req)
76105 if err != nil {
@@ -78,15 +107,19 @@ func (s *Scraper) fetch(ctx context.Context, url string) (io.Reader, error) {
78107 }
79108 defer resp.Body.Close()
80109
81- if resp.StatusCode != http.StatusOK {
82- return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
110+ if resp.StatusCode == http.StatusOK {
111+ data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
112+ if err != nil {
113+ return nil, fmt.Errorf("reading body: %w", err)
114+ }
115+ return bytes.NewReader(data), nil
83116 }
84117
85- data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
86- if err != nil {
87- return nil, fmt.Errorf("reading body: %w", err)
118+ se := &httpclient.StatusError{StatusCode: resp.StatusCode}
119+ if resp.StatusCode == http.StatusTooManyRequests {
120+ se.RetryAfter = httpclient.ParseRetryAfter(resp.Header.Get("Retry-After"))
88121 }
89- return bytes.NewReader(data), nil
122+ return nil, se
90123 }
91124
92125 func extractContent(r io.Reader) (string, error) {
@@ -3,6 +3,7 @@ package scraper
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
@@ -10,6 +11,8 @@ import (
10 "strings"11 "strings"
11 "time"12 "time"
12 13
14+ "pkg.rbrt.fr/glean/internal/httpclient"
15+
13 "golang.org/x/net/html"16 "golang.org/x/net/html"
14 )17 )
15 18
@@ -22,7 +25,8 @@ type Scraper struct {
22 func New(logger *slog.Logger) *Scraper {25 func New(logger *slog.Logger) *Scraper {
23 return &Scraper{26 return &Scraper{
24 client: &http.Client{27 client: &http.Client{
25- Timeout: 15 * time.Second,28+ Timeout: 15 * time.Second,
29+ Transport: httpclient.NewTransport(),
26 CheckRedirect: func(req *http.Request, via []*http.Request) error {30 CheckRedirect: func(req *http.Request, via []*http.Request) error {
27 if len(via) >= 10 {31 if len(via) >= 10 {
28 return fmt.Errorf("too many redirects")32 return fmt.Errorf("too many redirects")
@@ -66,11 +70,36 @@ func (s *Scraper) scrapeArchive(ctx context.Context, articleURL string) (string,
66 }70 }
67 71
68 func (s *Scraper) fetch(ctx context.Context, url string) (io.Reader, error) {72 func (s *Scraper) fetch(ctx context.Context, url string) (io.Reader, error) {
73+ reader, err := s.doFetch(ctx, url)
74+ if err == nil {
75+ return reader, nil
76+ }
77+
78+ var se *httpclient.StatusError
79+ if !errors.As(err, &se) || !httpclient.IsRetryable(se.StatusCode) {
80+ return nil, err
81+ }
82+
83+ backoff := 2 * time.Second
84+ if se.RetryAfter > 0 {
85+ backoff = min(se.RetryAfter, 5*time.Second)
86+ }
87+
88+ if err := httpclient.SleepWithContext(ctx, backoff); err != nil {
89+ return nil, err
90+ }
91+
92+ return s.doFetch(ctx, url)
93+}
94+
95+func (s *Scraper) doFetch(ctx context.Context, url string) (io.Reader, error) {
69 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)96 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
70 if err != nil {97 if err != nil {
71 return nil, err98 return nil, err
72 }99 }
73- req.Header.Set("User-Agent", "Glean/1.0 (RSS Reader)")100+
101+ httpclient.SetDefaultHeaders(req)
102+ req.Header.Set("Accept", httpclient.AcceptHTML)
74 103
75 resp, err := s.client.Do(req)104 resp, err := s.client.Do(req)
76 if err != nil {105 if err != nil {
@@ -78,15 +107,19 @@ func (s *Scraper) fetch(ctx context.Context, url string) (io.Reader, error) {
78 }107 }
79 defer resp.Body.Close()108 defer resp.Body.Close()
80 109
81- if resp.StatusCode != http.StatusOK {110+ if resp.StatusCode == http.StatusOK {
82- return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)111+ data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
112+ if err != nil {
113+ return nil, fmt.Errorf("reading body: %w", err)
114+ }
115+ return bytes.NewReader(data), nil
83 }116 }
84 117
85- data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))118+ se := &httpclient.StatusError{StatusCode: resp.StatusCode}
86- if err != nil {119+ if resp.StatusCode == http.StatusTooManyRequests {
87- return nil, fmt.Errorf("reading body: %w", err)120+ se.RetryAfter = httpclient.ParseRetryAfter(resp.Header.Get("Retry-After"))
88 }121 }
89- return bytes.NewReader(data), nil122+ return nil, se
90 }123 }
91 124
92 func extractContent(r io.Reader) (string, error) {125 func extractContent(r io.Reader) (string, error) {