diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..467f4f5 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,48 @@ +You are working on `git.codelab.vc/pkg/httpx`, a Go 1.24 HTTP client/server library with zero external dependencies. + +## Architecture + +- Client middleware: `func(http.RoundTripper) http.RoundTripper` — compose with `middleware.Chain` +- Server middleware: `func(http.Handler) http.Handler` — compose with `server.Chain` +- All configuration uses functional options pattern (`WithXxx` functions) +- Chain order for client: Logging → User MW → Retry → Circuit Breaker → Balancer → Transport + +## Package structure + +- `httpx` (root) — Client, request builders (NewJSONRequest, NewFormRequest), error types +- `middleware/` — client-side middleware (Logging, Recovery, Auth, Headers, RequestID) +- `retry/` — retry middleware with exponential backoff and Retry-After support +- `circuitbreaker/` — per-host circuit breaker (sync.Map of host → Breaker) +- `balancer/` — load balancing with health checking (RoundRobin, Weighted, Failover) +- `server/` — Server, Router, server middleware (RequestID, Recovery, Logging, CORS, RateLimit, MaxBodySize, Timeout), response helpers (WriteJSON, WriteError) +- `internal/requestid/` — shared context key (avoids circular import between server and middleware) +- `internal/clock/` — deterministic time for tests + +## Code conventions + +- Zero external dependencies — stdlib only, do not add imports outside the module +- Functional options: `type Option func(*options)` with `With` constructors +- Test with stdlib only: `testing`, `httptest`, `net/http`. No testify/gomock +- Client test helper: `mockTransport(fn)` wrapping `middleware.RoundTripperFunc` +- Server test helper: `httptest.NewRecorder`, `httptest.NewRequest`, `waitForAddr(t, srv)` +- Thread safety with `sync.Mutex`, `sync.Map`, or `atomic` +- Use `internal/clock` for time-dependent tests, not `time.Now()` directly +- Sentinel errors in sub-packages, re-exported as aliases in root package + +## When writing new code + +- Client middleware → file in `middleware/`, return `middleware.Middleware` +- Server middleware → file in `server/middleware_.go`, return `server.Middleware` +- New option → add field to options struct, create `With` func, apply in constructor +- Do NOT import `server` from `middleware` or vice versa (use `internal/requestid` for shared context) +- Client.Close() must be called when using WithEndpoints() (stops health checker goroutine) +- Request bodies must have GetBody set for retry — use NewJSONRequest/NewFormRequest + +## Commands + +```bash +go build ./... # compile +go test ./... # test +go test -race ./... # test with race detector +go vet ./... # static analysis +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6ab0832 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# Copilot instructions — httpx + +## Project + +`git.codelab.vc/pkg/httpx` is a Go 1.24 HTTP client and server library with zero external dependencies. + +## Architecture + +### Client (`httpx` root package) +- Middleware type: `func(http.RoundTripper) http.RoundTripper` +- Chain assembly (outermost → innermost): Logging → User MW → Retry → Circuit Breaker → Balancer → Transport +- Retry wraps CB+Balancer so each attempt can hit a different endpoint +- Circuit breaker is per-host (sync.Map of host → Breaker) +- Client.Close() required when using WithEndpoints() — stops health checker goroutine + +### Server (`server/` package) +- Middleware type: `func(http.Handler) http.Handler` +- Router wraps http.ServeMux with groups, prefix routing, Mount for sub-handlers +- Defaults() preset: RequestID → Recovery → Logging + production timeouts +- Available middleware: RequestID, Recovery, Logging, CORS, RateLimit, MaxBodySize, Timeout +- WriteJSON/WriteError for JSON responses + +### Sub-packages +- `middleware/` — client-side middleware (Logging, Recovery, Auth, Headers, RequestID) +- `retry/` — retry with exponential backoff and Retry-After +- `circuitbreaker/` — per-host circuit breaker +- `balancer/` — load balancing with health checking +- `internal/requestid/` — shared context key between server and middleware +- `internal/clock/` — deterministic time for tests + +## Conventions + +- All configuration uses functional options (`WithXxx` functions) +- Zero external dependencies — do not add requires to go.mod +- Tests use stdlib only (testing, httptest) — no testify or gomock +- Thread safety with sync.Mutex, sync.Map, or atomic +- Client test mock: `mockTransport(fn)` using `middleware.RoundTripperFunc` +- Server test helpers: `httptest.NewRecorder`, `httptest.NewRequest` +- Do NOT import server from middleware or vice versa — use internal/requestid for shared context +- Sentinel errors in sub-packages, re-exported in root package +- Use internal/clock for time-dependent tests + +## Commands + +```bash +go build ./... # compile +go test ./... # test +go test -race ./... # test with race detector +go vet ./... # static analysis +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..17aa0c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,139 @@ +# AGENTS.md — httpx + +Universal guide for AI coding agents working with this codebase. + +## Overview + +`git.codelab.vc/pkg/httpx` is a Go HTTP toolkit with **zero external dependencies** (Go 1.24, stdlib only). It provides: +- A composable HTTP **client** with retry, circuit breaking, load balancing +- A production-ready HTTP **server** with routing, middleware, graceful shutdown + +## Package map + +``` +httpx/ Root — Client, request builders, error types +├── middleware/ Client-side middleware (RoundTripper wrappers) +├── retry/ Retry middleware with backoff +├── circuitbreaker/ Per-host circuit breaker +├── balancer/ Client-side load balancing + health checking +├── server/ Server, Router, server-side middleware, response helpers +└── internal/ + ├── requestid/ Shared context key (avoids circular imports) + └── clock/ Deterministic time for testing +``` + +## Middleware chain architecture + +### Client middleware: `func(http.RoundTripper) http.RoundTripper` + +``` +Request flow (outermost → innermost): + + Logging + └→ User Middlewares + └→ Retry + └→ Circuit Breaker + └→ Balancer + └→ Base Transport (http.DefaultTransport) +``` + +Retry wraps CB+Balancer so each attempt can hit a different endpoint. + +### Server middleware: `func(http.Handler) http.Handler` + +``` +Chain(A, B, C)(handler) == A(B(C(handler))) +A is outermost (sees request first, response last) +``` + +Defaults() preset: `RequestID → Recovery → Logging` + +## Common tasks + +### Add a client middleware + +1. Create file in `middleware/` (or inline) +2. Return `middleware.Middleware` (`func(http.RoundTripper) http.RoundTripper`) +3. Use `middleware.RoundTripperFunc` for the inner adapter +4. Test with `middleware.RoundTripperFunc` as mock transport + +```go +func MyMiddleware() middleware.Middleware { + return func(next http.RoundTripper) http.RoundTripper { + return middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + // before + resp, err := next.RoundTrip(req) + // after + return resp, err + }) + } +} +``` + +### Add a server middleware + +1. Create file in `server/` named `middleware_.go` +2. Return `server.Middleware` (`func(http.Handler) http.Handler`) +3. Use `server.statusWriter` if you need to capture the response status +4. Test with `httptest.NewRecorder` + `httptest.NewRequest` + +```go +func MyMiddleware() Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // before + next.ServeHTTP(w, r) + // after + }) + } +} +``` + +### Add a route + +```go +r := server.NewRouter() +r.HandleFunc("GET /users/{id}", getUser) +r.HandleFunc("POST /users", createUser) + +// Group with prefix + middleware +api := r.Group("/api/v1", authMiddleware) +api.HandleFunc("GET /items", listItems) + +// Mount sub-handler +r.Mount("/health", server.HealthHandler()) +``` + +### Add a functional option + +1. Add field to the options struct (`clientOptions` or `serverOptions`) +2. Create `With` function returning `Option` +3. Apply the field in the constructor (`New`) + +## Gotchas + +- **Middleware order matters**: Retry wraps CB+Balancer intentionally — each retry attempt can hit a different endpoint and a different circuit breaker +- **Circular imports via `internal/`**: Both `server` and `middleware` packages need request ID context. The shared key lives in `internal/requestid` — do NOT import `server` from `middleware` or vice versa +- **Client.Close() is required** when using `WithEndpoints()` — the balancer starts a background health checker goroutine that must be stopped +- **GetBody for retries**: Request bodies must be replayable. Use `NewJSONRequest`/`NewFormRequest` (they set `GetBody`) or set it manually +- **statusWriter.Unwrap()**: Server middleware must not type-assert `http.ResponseWriter` directly — use `http.ResponseController` which calls `Unwrap()` to find `http.Flusher`, `http.Hijacker`, etc. +- **No external deps**: This is a zero-dependency library. Do not add any `require` to `go.mod` + +## Commands + +```bash +go build ./... # compile +go test ./... # all tests +go test -race ./... # tests with race detector +go test -v -run TestName ./package/ # single test +go vet ./... # static analysis +``` + +## Conventions + +- **Functional options** for all configuration (client and server) +- **stdlib only** testing — no testify, no gomock +- **Thread safety** — use `sync.Mutex`, `sync.Map`, or `atomic` where needed +- **`internal/clock`** — use for deterministic time in tests (never `time.Now()` directly in testable code) +- **Test helpers**: `mockTransport(fn)` wrapping `middleware.RoundTripperFunc` (client), `httptest.NewRecorder`/`httptest.NewRequest` (server), `waitForAddr(t, srv)` for server integration tests +- **Sentinel errors** live in sub-packages, root package re-exports as aliases