Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project are documented in this file.

## 0.3.5 - 2026-02-24

### Changed

- Reduced download package test suite runtime from ~92s to ~1s by making retry backoff injectable and eliminating unnecessary wall-clock sleeps in test server handlers.
- Test HTTP handlers now respect request context cancellation so `httptest.Server.Close()` returns immediately instead of blocking on in-flight handlers.

## 0.3.4 - 2026-02-24

### Added
Expand Down
17 changes: 11 additions & 6 deletions internal/download/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ type DownloadResult struct {
Duration time.Duration // Total download duration
}

// BackoffFunc calculates the delay before a retry attempt.
type BackoffFunc func(attempt int) time.Duration

// Client performs HTTP downloads with retry logic, resumption, and validation.
type Client struct {
httpClient *http.Client
logger *slog.Logger
userAgent string
httpClient *http.Client
logger *slog.Logger
userAgent string
backoffFunc BackoffFunc
}

// NewClient creates a new download client with the given logger.
Expand All @@ -65,8 +69,9 @@ func NewClient(logger *slog.Logger) *Client {
// No overall Timeout — body reads can take as long as needed.
// Context cancellation still works for user-initiated cancel.
},
logger: logger,
userAgent: "airgap/1.0",
logger: logger,
userAgent: "airgap/1.0",
backoffFunc: calculateBackoffDelay,
}
}

Expand Down Expand Up @@ -159,7 +164,7 @@ func (c *Client) Download(ctx context.Context, opts DownloadOptions) (*DownloadR

// Wait before retrying with exponential backoff + jitter
if attempt < opts.RetryCount {
delay := calculateBackoffDelay(attempt)
delay := c.backoffFunc(attempt)
c.logger.Debug("retrying download", "url", opts.URL, "delay", delay)
select {
case <-time.After(delay):
Expand Down
74 changes: 48 additions & 26 deletions internal/download/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ import (
"time"
)

// newTestClient creates a client with zero-delay backoff for fast tests.
func newTestClient(logger *slog.Logger) *Client {
c := NewClient(logger)
c.backoffFunc = func(attempt int) time.Duration { return 0 }
return c
}

// TestNewClient creates client with logger
func TestNewClient(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

if client == nil {
t.Fatal("expected client to be non-nil")
Expand Down Expand Up @@ -49,7 +56,7 @@ func TestDownloadFile(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -114,7 +121,7 @@ func TestDownloadFileWithHeaders(t *testing.T) {
tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "header.bin")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -154,7 +161,7 @@ func TestDownloadFileWithChecksum(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_checksum.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -195,7 +202,7 @@ func TestDownloadFileChecksumMismatch(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_bad_checksum.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000"

Expand Down Expand Up @@ -231,7 +238,7 @@ func TestDownloadFileNotFound(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_404.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -264,7 +271,7 @@ func TestDownloadFileServerError(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_500.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -304,7 +311,7 @@ func TestDownloadFileRetry(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_retry.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -368,7 +375,7 @@ func TestDownloadFileResume(t *testing.T) {
}

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

result, err := client.Download(context.Background(), DownloadOptions{
URL: server.URL,
Expand Down Expand Up @@ -400,27 +407,34 @@ func TestDownloadFileResume(t *testing.T) {

// TestDownloadFileTimeout httptest with delayed response, verify timeout handling
func TestDownloadFileTimeout(t *testing.T) {
serverCtx, serverCancel := context.WithCancel(context.Background())
defer serverCancel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate a slow server by sleeping longer than the timeout
time.Sleep(60 * time.Second)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("This should timeout"))
// Block until either request or server context is done, so
// server.Close() doesn't wait for the full sleep duration.
select {
case <-r.Context().Done():
case <-serverCtx.Done():
case <-time.After(30 * time.Second):
}
}))
defer server.Close()

tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "testfile_timeout.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

// Create a context with a short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()

result, err := client.Download(ctx, DownloadOptions{
URL: server.URL,
DestPath: destPath,
URL: server.URL,
DestPath: destPath,
RetryCount: 1,
})

if err == nil {
Expand All @@ -430,16 +444,24 @@ func TestDownloadFileTimeout(t *testing.T) {
if result != nil {
t.Fatal("expected result to be nil on timeout")
}

// Signal server handler to stop so server.Close() returns quickly.
serverCancel()
}

// TestDownloadFileContextCancellation verifies that context cancellation stops the download
func TestDownloadFileContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Send a large response slowly
for i := 0; i < 100; i++ {
_, _ = w.Write([]byte("chunk"))
w.(http.Flusher).Flush()
time.Sleep(100 * time.Millisecond)
// Send chunks slowly; stop when the request context is cancelled
// so server.Close() returns promptly.
for i := 0; i < 50; i++ {
select {
case <-r.Context().Done():
return
case <-time.After(10 * time.Millisecond):
_, _ = w.Write([]byte("chunk"))
w.(http.Flusher).Flush()
}
}
}))
defer server.Close()
Expand All @@ -448,13 +470,13 @@ func TestDownloadFileContextCancellation(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_cancel.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

ctx, cancel := context.WithCancel(context.Background())

// Cancel after a short delay
go func() {
time.Sleep(200 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
cancel()
}()

Expand Down Expand Up @@ -488,7 +510,7 @@ func TestDownloadFileProgress(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_progress.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

progressCallCount := 0
onProgress := func(bytesDownloaded, totalBytes int64) {
Expand Down Expand Up @@ -529,7 +551,7 @@ func TestDownloadFileSizeValidation(t *testing.T) {
destPath := filepath.Join(tmpDir, "testfile_size.bin")

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

// Expect a different size
expectedSize := int64(len(testContent) + 100)
Expand Down
44 changes: 21 additions & 23 deletions internal/download/pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
// TestNewPool creates pool with given workers
func TestNewPool(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

pool := NewPool(client, 5, logger)

Expand All @@ -41,7 +41,7 @@ func TestNewPool(t *testing.T) {
// TestNewPoolDefaultWorkers verifies pool defaults to 1 worker if workers <= 0
func TestNewPoolDefaultWorkers(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

pool := NewPool(client, 0, logger)

Expand Down Expand Up @@ -81,7 +81,7 @@ func TestPoolExecute(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 3, logger)

jobs := []Job{
Expand Down Expand Up @@ -148,7 +148,7 @@ func TestPoolConcurrency(t *testing.T) {
mu.Unlock()

// Simulate some work
time.Sleep(100 * time.Millisecond)
time.Sleep(20 * time.Millisecond)

w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
Expand All @@ -159,7 +159,7 @@ func TestPoolConcurrency(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)

// Create pool with 4 workers
pool := NewPool(client, 4, logger)
Expand Down Expand Up @@ -208,7 +208,7 @@ func TestPoolWithFailures(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 2, logger)

jobs := make([]Job, 6)
Expand Down Expand Up @@ -259,18 +259,22 @@ func TestPoolWithFailures(t *testing.T) {
// TestPoolContextCancellation cancel context mid-execution, verify pool stops
func TestPoolContextCancellation(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate a slow server
for i := 0; i < 20; i++ {
time.Sleep(100 * time.Millisecond)
_, _ = w.Write([]byte("chunk"))
// Send chunks slowly; respect request context so server.Close() is fast.
for i := 0; i < 50; i++ {
select {
case <-r.Context().Done():
return
case <-time.After(10 * time.Millisecond):
_, _ = w.Write([]byte("chunk"))
}
}
}))
defer slowServer.Close()

tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 2, logger)

// Create multiple jobs
Expand All @@ -286,7 +290,7 @@ func TestPoolContextCancellation(t *testing.T) {

// Cancel context after a short delay
go func() {
time.Sleep(250 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
cancel()
}()

Expand All @@ -298,19 +302,13 @@ func TestPoolContextCancellation(t *testing.T) {
}

// Not all should succeed due to cancellation
successCount := 0
failureCount := 0

for _, result := range results {
if result.Success {
successCount++
} else {
if !result.Success {
failureCount++
}
}

t.Logf("Results after cancellation: %d successes, %d failures", successCount, failureCount)

if failureCount == 0 {
t.Fatal("expected some failures due to context cancellation")
}
Expand All @@ -319,7 +317,7 @@ func TestPoolContextCancellation(t *testing.T) {
// TestPoolEmptyJobs verifies pool handles empty job list
func TestPoolEmptyJobs(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 3, logger)

results := pool.Execute(context.Background(), []Job{})
Expand All @@ -341,7 +339,7 @@ func TestPoolResultOrder(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 5, logger) // More workers than jobs

// Create jobs with identifiable URLs
Expand Down Expand Up @@ -390,7 +388,7 @@ func TestPoolWithChecksumValidation(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 2, logger)

// Calculate correct checksum
Expand Down Expand Up @@ -441,7 +439,7 @@ func TestPoolSingleWorker(t *testing.T) {
tmpDir := t.TempDir()

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
client := NewClient(logger)
client := newTestClient(logger)
pool := NewPool(client, 1, logger)

jobs := make([]Job, 3)
Expand Down
Loading