All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
125 lines
3.7 KiB
Go
125 lines
3.7 KiB
Go
// Package httputil provides shared HTTP helpers used by both the runner and
|
|
// backend binaries. It has no imports from this module — only the standard
|
|
// library — so it is safe to import from anywhere in the dependency graph.
|
|
package httputil
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Client is the minimal interface for making HTTP GET requests.
|
|
// *http.Client satisfies this interface.
|
|
type Client interface {
|
|
Do(req *http.Request) (*http.Response, error)
|
|
}
|
|
|
|
// ErrMaxRetries is returned when RetryGet exhausts all attempts.
|
|
var ErrMaxRetries = errors.New("httputil: max retries exceeded")
|
|
|
|
// errClientError is returned by doGet for 4xx responses; it signals that the
|
|
// request should NOT be retried (the client is at fault).
|
|
var errClientError = errors.New("httputil: client error")
|
|
|
|
// RetryGet fetches url using client, retrying on network errors or 5xx
|
|
// responses with exponential backoff. It returns the full response body as a
|
|
// string on success.
|
|
//
|
|
// - maxAttempts: total number of attempts (must be >= 1)
|
|
// - baseDelay: initial wait before the second attempt; doubles each retry
|
|
func RetryGet(ctx context.Context, client Client, url string, maxAttempts int, baseDelay time.Duration) (string, error) {
|
|
if maxAttempts < 1 {
|
|
maxAttempts = 1
|
|
}
|
|
delay := baseDelay
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(delay):
|
|
}
|
|
delay *= 2
|
|
}
|
|
|
|
body, err := doGet(ctx, client, url)
|
|
if err == nil {
|
|
return body, nil
|
|
}
|
|
lastErr = err
|
|
|
|
// Do not retry on context cancellation.
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
// Do not retry on 4xx — the client is at fault.
|
|
if errors.Is(err, errClientError) {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("%w after %d attempts: %w", ErrMaxRetries, maxAttempts, lastErr)
|
|
}
|
|
|
|
func doGet(ctx context.Context, client Client, url string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("GET %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return "", fmt.Errorf("GET %s: server error %d", url, resp.StatusCode)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("%w: GET %s: client error %d", errClientError, url, resp.StatusCode)
|
|
}
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read body %s: %w", url, err)
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
// WriteJSON writes v as JSON to w with the given HTTP status code and sets the
|
|
// Content-Type header to application/json.
|
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// WriteError writes a JSON error object {"error": msg} with the given status.
|
|
func WriteError(w http.ResponseWriter, status int, msg string) {
|
|
WriteJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// maxBodyBytes is the limit applied by DecodeJSON to prevent unbounded reads.
|
|
const maxBodyBytes = 1 << 20 // 1 MiB
|
|
|
|
// DecodeJSON decodes a JSON request body into v. It enforces a 1 MiB size
|
|
// limit and returns a descriptive error on any failure.
|
|
func DecodeJSON(r *http.Request, v any) error {
|
|
r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return fmt.Errorf("decode JSON body: %w", err)
|
|
}
|
|
return nil
|
|
}
|