Moved common HTTP polling helpers into feedkit and implemented support for ETag and Last-Modified
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,7 +29,80 @@ func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
|
||||
func FetchBody(ctx context.Context, client *http.Client, url, userAgent, accept string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
res, err := doRequest(ctx, client, http.MethodGet, url, userAgent, accept, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %s", res.Status)
|
||||
}
|
||||
|
||||
return readValidatedBody(res.Body)
|
||||
}
|
||||
|
||||
// HTTPValidators are cache validators learned from prior successful GET responses.
|
||||
//
|
||||
// ETag is preferred when present. LastModified is used as a fallback validator
|
||||
// when ETag is unavailable.
|
||||
type HTTPValidators struct {
|
||||
ETag string
|
||||
LastModified string
|
||||
}
|
||||
|
||||
// FetchBodyIfChanged performs an HTTP GET and opportunistically uses conditional
|
||||
// request headers based on the provided validators.
|
||||
//
|
||||
// Behavior:
|
||||
// - if conditional is false, this behaves like a normal GET and leaves validators unchanged
|
||||
// - if validators.ETag is set, sends If-None-Match
|
||||
// - else if validators.LastModified is set, sends If-Modified-Since
|
||||
// - 304 Not Modified is treated as success with changed=false and no body
|
||||
// - 200 responses are treated as changed=true and still enforce the normal body checks
|
||||
//
|
||||
// Returned validators reflect any updates learned from the response headers.
|
||||
func FetchBodyIfChanged(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
url, userAgent, accept string,
|
||||
conditional bool,
|
||||
validators HTTPValidators,
|
||||
) ([]byte, bool, HTTPValidators, error) {
|
||||
headerName, headerValue := conditionalHeader(conditional, validators)
|
||||
|
||||
res, err := doRequest(ctx, client, http.MethodGet, url, userAgent, accept, headerName, headerValue)
|
||||
if err != nil {
|
||||
return nil, false, validators, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
switch res.StatusCode {
|
||||
case http.StatusNotModified:
|
||||
if conditional {
|
||||
validators = refreshValidators(validators, res.Header)
|
||||
}
|
||||
return nil, false, validators, nil
|
||||
default:
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, false, validators, fmt.Errorf("HTTP %s", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := readValidatedBody(res.Body)
|
||||
if err != nil {
|
||||
return nil, false, validators, err
|
||||
}
|
||||
|
||||
if conditional {
|
||||
validators = replaceValidators(res.Header)
|
||||
}
|
||||
|
||||
return b, true, validators, nil
|
||||
}
|
||||
|
||||
func doRequest(ctx context.Context, client *http.Client, method, url, userAgent, accept, headerName, headerValue string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -39,19 +113,46 @@ func FetchBody(ctx context.Context, client *http.Client, url, userAgent, accept
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %s", res.Status)
|
||||
if headerName != "" && headerValue != "" {
|
||||
req.Header.Set(headerName, headerValue)
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func conditionalHeader(enabled bool, validators HTTPValidators) (string, string) {
|
||||
if !enabled {
|
||||
return "", ""
|
||||
}
|
||||
if etag := strings.TrimSpace(validators.ETag); etag != "" {
|
||||
return "If-None-Match", etag
|
||||
}
|
||||
if lastModified := strings.TrimSpace(validators.LastModified); lastModified != "" {
|
||||
return "If-Modified-Since", lastModified
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func replaceValidators(header http.Header) HTTPValidators {
|
||||
return HTTPValidators{
|
||||
ETag: strings.TrimSpace(header.Get("ETag")),
|
||||
LastModified: strings.TrimSpace(header.Get("Last-Modified")),
|
||||
}
|
||||
}
|
||||
|
||||
func refreshValidators(current HTTPValidators, header http.Header) HTTPValidators {
|
||||
if etag := strings.TrimSpace(header.Get("ETag")); etag != "" {
|
||||
current.ETag = etag
|
||||
}
|
||||
if lastModified := strings.TrimSpace(header.Get("Last-Modified")); lastModified != "" {
|
||||
current.LastModified = lastModified
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func readValidatedBody(r io.Reader) ([]byte, error) {
|
||||
// Read at most maxResponseBodyBytes + 1 so we can detect overflow.
|
||||
limited := io.LimitReader(res.Body, maxResponseBodyBytes+1)
|
||||
limited := io.LimitReader(r, maxResponseBodyBytes+1)
|
||||
|
||||
b, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user