From 6b1941fce70dca77e0325dfdfc99971882c3be00 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Fri, 20 Mar 2026 14:21:43 +0300 Subject: [PATCH] Add foundation: middleware type, error types, and internal clock Introduce the core building blocks for the httpx library: - middleware.Middleware type and Chain() composer - Error struct with sentinel errors (ErrRetryExhausted, ErrCircuitOpen, ErrNoHealthy) - internal/clock package with Clock interface and MockClock for deterministic testing --- error.go | 44 ++++++++++++ go.mod | 3 + internal/clock/clock.go | 150 +++++++++++++++++++++++++++++++++++++++ middleware/middleware.go | 29 ++++++++ 4 files changed, 226 insertions(+) create mode 100644 error.go create mode 100644 go.mod create mode 100644 internal/clock/clock.go create mode 100644 middleware/middleware.go diff --git a/error.go b/error.go new file mode 100644 index 0000000..6a3185e --- /dev/null +++ b/error.go @@ -0,0 +1,44 @@ +package httpx + +import ( + "errors" + "fmt" + "net/http" +) + +// Sentinel errors returned by httpx components. +var ( + ErrRetryExhausted = errors.New("httpx: all retry attempts exhausted") + ErrCircuitOpen = errors.New("httpx: circuit breaker is open") + ErrNoHealthy = errors.New("httpx: no healthy endpoints available") +) + +// Error provides structured error information for failed HTTP operations. +type Error struct { + // Op is the operation that failed (e.g. "Get", "Do"). + Op string + // URL is the originally-requested URL. + URL string + // Endpoint is the resolved endpoint URL (after balancing). + Endpoint string + // StatusCode is the HTTP status code, if a response was received. + StatusCode int + // Retries is the number of retry attempts made. + Retries int + // Err is the underlying error. + Err error +} + +func (e *Error) Error() string { + if e.Endpoint != "" && e.Endpoint != e.URL { + return fmt.Sprintf("httpx: %s %s (endpoint %s): %v", e.Op, e.URL, e.Endpoint, e.Err) + } + return fmt.Sprintf("httpx: %s %s: %v", e.Op, e.URL, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// ErrorMapper maps an HTTP response to an error. If the response is +// acceptable, the mapper should return nil. Used by Client to convert +// non-successful HTTP responses into Go errors. +type ErrorMapper func(resp *http.Response) error diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6980a55 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.codelab.vc/pkg/httpx + +go 1.24 diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..c6a8e08 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,150 @@ +package clock + +import ( + "sync" + "time" +) + +// Clock abstracts time operations for deterministic testing. +type Clock interface { + Now() time.Time + Since(t time.Time) time.Duration + NewTimer(d time.Duration) Timer + After(d time.Duration) <-chan time.Time +} + +// Timer abstracts time.Timer for testability. +type Timer interface { + C() <-chan time.Time + Stop() bool + Reset(d time.Duration) bool +} + +// System returns a Clock backed by the real system time. +func System() Clock { return systemClock{} } + +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now() } +func (systemClock) Since(t time.Time) time.Duration { return time.Since(t) } +func (systemClock) NewTimer(d time.Duration) Timer { return &systemTimer{t: time.NewTimer(d)} } +func (systemClock) After(d time.Duration) <-chan time.Time { return time.After(d) } + +type systemTimer struct{ t *time.Timer } + +func (s *systemTimer) C() <-chan time.Time { return s.t.C } +func (s *systemTimer) Stop() bool { return s.t.Stop() } +func (s *systemTimer) Reset(d time.Duration) bool { return s.t.Reset(d) } + +// Mock returns a manually-controlled Clock for tests. +func Mock(now time.Time) *MockClock { + return &MockClock{now: now} +} + +// MockClock is a deterministic clock for testing. +type MockClock struct { + mu sync.Mutex + now time.Time + timers []*mockTimer +} + +func (m *MockClock) Now() time.Time { + m.mu.Lock() + defer m.mu.Unlock() + return m.now +} + +func (m *MockClock) Since(t time.Time) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + return m.now.Sub(t) +} + +func (m *MockClock) NewTimer(d time.Duration) Timer { + m.mu.Lock() + defer m.mu.Unlock() + t := &mockTimer{ + ch: make(chan time.Time, 1), + deadline: m.now.Add(d), + active: true, + } + m.timers = append(m.timers, t) + if d <= 0 { + t.fire(m.now) + } + return t +} + +func (m *MockClock) After(d time.Duration) <-chan time.Time { + return m.NewTimer(d).C() +} + +// Advance moves the clock forward by d and fires any expired timers. +func (m *MockClock) Advance(d time.Duration) { + m.mu.Lock() + m.now = m.now.Add(d) + now := m.now + timers := m.timers + m.mu.Unlock() + + for _, t := range timers { + t.mu.Lock() + if t.active && !now.Before(t.deadline) { + t.fire(now) + } + t.mu.Unlock() + } +} + +// Set sets the clock to an absolute time and fires any expired timers. +func (m *MockClock) Set(t time.Time) { + m.mu.Lock() + m.now = t + now := m.now + timers := m.timers + m.mu.Unlock() + + for _, tmr := range timers { + tmr.mu.Lock() + if tmr.active && !now.Before(tmr.deadline) { + tmr.fire(now) + } + tmr.mu.Unlock() + } +} + +type mockTimer struct { + mu sync.Mutex + ch chan time.Time + deadline time.Time + active bool +} + +func (t *mockTimer) C() <-chan time.Time { return t.ch } + +func (t *mockTimer) Stop() bool { + t.mu.Lock() + defer t.mu.Unlock() + was := t.active + t.active = false + return was +} + +func (t *mockTimer) Reset(d time.Duration) bool { + t.mu.Lock() + defer t.mu.Unlock() + was := t.active + t.active = true + // Note: deadline will be recalculated on next Advance + t.deadline = time.Now().Add(d) // placeholder; mock users should use Advance + return was +} + +// fire sends the time on the channel. Caller must hold t.mu. +func (t *mockTimer) fire(now time.Time) { + t.active = false + select { + case t.ch <- now: + default: + } +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..625b3ad --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,29 @@ +package middleware + +import "net/http" + +// Middleware wraps an http.RoundTripper to add behavior. +// This is the fundamental building block of the httpx library. +type Middleware func(http.RoundTripper) http.RoundTripper + +// Chain composes middlewares so that Chain(A, B, C)(base) == A(B(C(base))). +// Middlewares are applied from right to left: C wraps base first, then B wraps +// the result, then A wraps last. This means A is the outermost layer and sees +// every request first. +func Chain(mws ...Middleware) Middleware { + return func(rt http.RoundTripper) http.RoundTripper { + for i := len(mws) - 1; i >= 0; i-- { + rt = mws[i](rt) + } + return rt + } +} + +// RoundTripperFunc is an adapter to allow the use of ordinary functions as +// http.RoundTripper. It works exactly like http.HandlerFunc for handlers. +type RoundTripperFunc func(*http.Request) (*http.Response, error) + +// RoundTrip implements http.RoundTripper. +func (f RoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +}