Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ Architecture refactor — no user-facing API changes.
- Hard-coded 10 MiB body cap in `DoRequest`.
- Duplicated theme/flag/validation code in every `main.go`.
- Duplicated timeout/no-color/theme parsing in every `BuildToolConfig`.
- `NewTransportClient` (dead code).

### Fixed
- Error messages now consistently go to stderr (via slog).
Expand Down
2 changes: 1 addition & 1 deletion cmd/media-calendar/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func main() {
cfg.NoColor = cu.NoColor
cfg.Theme = cu.Theme
cfg.JSONOutput = cu.JSONFlag()
cfg.WatchMode = cu.Watch
cfg.Watch = cu.Watch
cfg.WatchSeconds = cu.WatchSeconds
cfg.Debug = cu.Debug
cfg.NoBanner = *noBanner
Expand Down
3 changes: 1 addition & 2 deletions internal/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,6 @@ type ToolConfig struct {
RadarrInstances []config.ArrInstance
Days int
DaysPast int
WatchMode bool
WatchSeconds int
NoBanner bool
Filter string
MonitoredOnly bool
Expand Down Expand Up @@ -149,6 +147,7 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig {
cfg.Timeout = 10 * time.Second
cfg.Days = 1
cfg.WatchSeconds = 300
cfg.Watch = false
return cfg
}

Expand Down
6 changes: 3 additions & 3 deletions internal/calendar/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func dataAgent(ctx context.Context, cfg ToolConfig, ch chan<- dataEvent) {
case <-ctx.Done():
return
}
if !cfg.WatchMode {
if !cfg.Watch {
return
}
if err != nil {
Expand Down Expand Up @@ -123,7 +123,7 @@ func runWithSubagents(ctx context.Context, cfg ToolConfig, p *colors.Palette) er
termWidth = re.width
case de := <-dataCh:
if de.err != nil {
if !cfg.WatchMode {
if !cfg.Watch {
return de.err
}
fmt.Fprintf(os.Stderr, "WARNING: %v (retrying)\n", de.err)
Expand All @@ -137,7 +137,7 @@ func runWithSubagents(ctx context.Context, cfg ToolConfig, p *colors.Palette) er
fmt.Print(colors.ClearScreen + colors.HomeCursor)
renderCalendar(cfg, items, issues, termWidth, p)

if !cfg.WatchMode {
if !cfg.Watch {
return nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cmdutil/cmdutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,11 @@ func (c *Common) JSONFlag() bool {
func LoadAndValidate() *config.ToolkitConfig {
tk, err := config.LoadToolkitConfig()
if err != nil {
slogx.Default().Warn("config not loaded, using defaults", "err", err)
fmt.Fprintf(os.Stderr, "WARN: config not loaded, using defaults: %v\n", err)
}
if tk != nil {
if err := tk.Validate(); err != nil {
slogx.Default().Warn("config validation", "err", err)
fmt.Fprintf(os.Stderr, "WARN: config validation: %v\n", err)
}
}
return tk
Expand Down
8 changes: 4 additions & 4 deletions internal/feed/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func fetchSonarrHistory(ctx context.Context, client *httputil.Client, inst confi
endpoint := fmt.Sprintf("%s/api/v3/history/since?date=%s&includeEpisode=true&includeSeries=true", inst.URL, sinceStr)

headers := map[string]string{"X-Api-Key": inst.APIKey}
body, status, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
body, status, _, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -126,7 +126,7 @@ func enrichSonarrSubtitles(ctx context.Context, client *httputil.Client, inst co
}

headers := map[string]string{"X-Api-Key": inst.APIKey}
body, status, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
body, status, _, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
if err != nil || status != http.StatusOK {
return
}
Expand Down Expand Up @@ -155,7 +155,7 @@ func fetchRadarrHistory(ctx context.Context, client *httputil.Client, inst confi
endpoint := fmt.Sprintf("%s/api/v3/history/since?date=%s&includeMovie=true", inst.URL, sinceStr)

headers := map[string]string{"X-Api-Key": inst.APIKey}
body, status, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
body, status, _, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -207,7 +207,7 @@ func enrichRadarrSubtitles(ctx context.Context, client *httputil.Client, inst co
}

headers := map[string]string{"X-Api-Key": inst.APIKey}
body, status, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
body, status, _, err := client.DoRequest(ctx, "GET", endpoint, headers, nil)
if err != nil || status != http.StatusOK {
return
}
Expand Down
44 changes: 34 additions & 10 deletions internal/httputil/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,36 +80,58 @@ func jitter(d time.Duration) time.Duration {
}

// DoRequest performs an HTTP request and returns the raw body bytes and HTTP status code.
func (c *Client) DoRequest(ctx context.Context, method, url string, headers map[string]string, body io.Reader) ([]byte, int, error) {
func (c *Client) DoRequest(ctx context.Context, method, url string, headers map[string]string, body io.Reader) ([]byte, int, time.Duration, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.Do(req)
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}
defer resp.Body.Close()

retryAfter := parseRetryAfterHeader(resp)

maxSize := c.MaxBodySize
if maxSize <= 0 {
maxSize = 10 * 1024 * 1024
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, resp.StatusCode, err
return nil, resp.StatusCode, retryAfter, err
}
if int64(len(data)) > maxSize {
return nil, resp.StatusCode, fmt.Errorf("response body too large (exceeds %d bytes)", maxSize)
return nil, resp.StatusCode, retryAfter, fmt.Errorf("response body too large (exceeds %d bytes)", maxSize)
}
return data, resp.StatusCode, retryAfter, nil
}

func parseRetryAfterHeader(resp *http.Response) time.Duration {
if resp == nil {
return 0
}
h := resp.Header.Get("Retry-After")
if h == "" {
return 0
}
return data, resp.StatusCode, nil
if s, err := strconv.Atoi(h); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
if t, err := time.Parse(time.RFC1123, h); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}

// DoJSON performs an HTTP request and decodes the JSON response into result.
func (c *Client) DoJSON(ctx context.Context, method, url string, headers map[string]string, body io.Reader, result interface{}) error {
data, status, err := c.DoRequest(ctx, method, url, headers, body)
data, status, _, err := c.DoRequest(ctx, method, url, headers, body)
if err != nil {
return err
}
Expand All @@ -126,7 +148,7 @@ func (c *Client) DoJSON(ctx context.Context, method, url string, headers map[str

// DoXML performs an HTTP request and decodes the XML response into result.
func (c *Client) DoXML(ctx context.Context, method, url string, headers map[string]string, body io.Reader, result interface{}) error {
data, status, err := c.DoRequest(ctx, method, url, headers, body)
data, status, _, err := c.DoRequest(ctx, method, url, headers, body)
if err != nil {
return err
}
Expand Down Expand Up @@ -172,11 +194,13 @@ type responseHandler func([]byte, int) error
func (c *Client) doWithRetry(ctx context.Context, method, url string, headers map[string]string, body io.Reader, fn responseHandler, rc RetryConfig) error {
backoff := rc.InitialBackoff
for attempt := 1; attempt <= rc.MaxAttempts; attempt++ {
data, status, err := c.DoRequest(ctx, method, url, headers, body)
data, status, retryAfter, err := c.DoRequest(ctx, method, url, headers, body)
if err == nil {
if rc.shouldRetry(status) && attempt < rc.MaxAttempts {
if rc.RespectRetryAfter {
if ra := parseRetryAfter(string(data)); ra > 0 && ra < rc.MaxBackoff {
if retryAfter > 0 && retryAfter < rc.MaxBackoff {
backoff = retryAfter
} else if ra := parseRetryAfter(string(data)); ra > 0 && ra < rc.MaxBackoff {
backoff = ra
}
}
Expand Down
27 changes: 25 additions & 2 deletions internal/httputil/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestDoRequest(t *testing.T) {
ctx := context.Background()
client := NewClient(5 * time.Second)

body, status, err := client.DoRequest(ctx, "GET", server.URL, nil, nil)
body, status, _, err := client.DoRequest(ctx, "GET", server.URL, nil, nil)
if err != nil {
t.Fatalf("DoRequest error: %v", err)
}
Expand Down Expand Up @@ -121,11 +121,34 @@ func TestLargeBodyRejection(t *testing.T) {
ctx := context.Background()
client := NewClient(5 * time.Second)

_, _, err := client.DoRequest(ctx, "GET", server.URL, nil, nil)
_, _, _, err := client.DoRequest(ctx, "GET", server.URL, nil, nil)
if err == nil {
t.Fatal("Expected error for large body, got nil")
}
if !strings.Contains(err.Error(), "too large") {
t.Errorf("Error should mention too large, got: %v", err)
}
}

func TestRetryAfterFromHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "30")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{}`))
}))
defer server.Close()

ctx := context.Background()
client := NewClient(5 * time.Second)

_, status, retryAfter, err := client.DoRequest(ctx, "GET", server.URL, nil, nil)
if err != nil {
t.Fatalf("DoRequest error: %v", err)
}
if status != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429", status)
}
if retryAfter != 30*time.Second {
t.Errorf("retryAfter = %v, want 30s", retryAfter)
}
}
Loading