From ec8a831362c1e00cd829304bb44de617cd17f8b1 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sun, 3 May 2026 13:46:32 +0200 Subject: [PATCH] feat: HTTP transport with KasFloodDelay gating and 5xx retry Add internal/transport.Client for posting SOAP envelopes to a KAS endpoint: - HTTP POST with text/xml; charset=utf-8 and a version-stamped User-Agent. - Exponential backoff on 5xx and network errors, MaxRetries configurable; 4xx and SOAP-level faults are surfaced without retry. - Per-instance gate consulted before each request: callers report the delay returned by the server via RecordDelay so successive Do calls honour the KAS-side rate limit. - Now and Sleep are injectable so retry timing and gate behaviour are asserted via httptest.Server without real-clock waits. Closes #4. --- CHANGELOG.md | 7 + internal/transport/client.go | 170 ++++++++++++++++++ internal/transport/client_test.go | 276 ++++++++++++++++++++++++++++++ internal/transport/doc.go | 15 +- 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 internal/transport/client.go create mode 100644 internal/transport/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8630333..502ad5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/transport` HTTP client wrapping the KAS SOAP endpoints: + POST with the SOAP 1.1 content type, version-stamped User-Agent, + exponential backoff on 5xx and network errors (4xx and SOAP faults + are returned without retry), context-aware cancellation, and a + per-client `RecordDelay`/gate pair so callers can honour the + server-side `KasFloodDelay`. `Now`/`Sleep` are injectable for + deterministic tests via `httptest.Server`. (Closes #4.) - `internal/config` profile-aware credentials loader: TOML config under the OS-specific user-config path (XDG on Linux), multi-profile, with resolution precedence flag > env > profile > default profile. Env diff --git a/internal/transport/client.go b/internal/transport/client.go new file mode 100644 index 0000000..51702bf --- /dev/null +++ b/internal/transport/client.go @@ -0,0 +1,170 @@ +package transport + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/chmmou/kasapi-cli/internal/version" +) + +// Defaults applied by New. +const ( + DefaultTimeout = 30 * time.Second + DefaultMaxRetries = 3 + defaultBackoff = 500 * time.Millisecond +) + +// DefaultUserAgent identifies the CLI to the KAS server. The version +// suffix is filled at build time via -ldflags. +var DefaultUserAgent = "kasapi-cli/" + version.Version + " (+https://github.com/chmmou/kasapi-cli)" + +// Client posts SOAP envelopes over HTTPS and gates outgoing requests +// behind a configurable per-instance delay. Callers report the next +// delay via RecordDelay after parsing the response body. +// +// A zero Client is unusable; obtain one with New. +type Client struct { + HTTPClient *http.Client + UserAgent string + MaxRetries int + + // Now and Sleep are overridable for tests so retry/flood-delay + // timing can be asserted without real wall-clock waits. + Now func() time.Time + Sleep func(ctx context.Context, d time.Duration) error + + mu sync.Mutex + nextEarliest time.Time +} + +// New returns a Client with sensible defaults: a 30s HTTP timeout, a +// version-stamped User-Agent, and three retries on 5xx / network errors. +func New() *Client { + return &Client{ + HTTPClient: &http.Client{Timeout: DefaultTimeout}, + UserAgent: DefaultUserAgent, + MaxRetries: DefaultMaxRetries, + Now: time.Now, + Sleep: ctxSleep, + } +} + +// RecordDelay schedules a window during which subsequent calls to Do +// will block. d is the KasFloodDelay reported by the server in the +// most recent response. Negative or zero values clear the gate. +func (c *Client) RecordDelay(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + if d <= 0 { + c.nextEarliest = time.Time{} + return + } + c.nextEarliest = c.now().Add(d) +} + +// Do posts body to endpoint, blocking first until any pending flood +// delay has elapsed. On 5xx and network errors, Do retries up to +// MaxRetries times with exponential backoff. 4xx responses and +// context cancellation return immediately. +func (c *Client) Do(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + if err := c.waitGate(ctx); err != nil { + return nil, err + } + + backoff := defaultBackoff + var lastErr error + for attempt := 0; attempt <= c.MaxRetries; attempt++ { + if attempt > 0 { + if err := c.Sleep(ctx, backoff); err != nil { + return nil, err + } + backoff *= 2 + } + resp, err := c.doOnce(ctx, endpoint, body) + if err == nil { + return resp, nil + } + var rerr *retryableError + if !errors.As(err, &rerr) { + return nil, err + } + lastErr = rerr.err + } + return nil, fmt.Errorf("transport: %d retries exhausted: %w", c.MaxRetries, lastErr) +} + +func (c *Client) waitGate(ctx context.Context) error { + c.mu.Lock() + var wait time.Duration + if !c.nextEarliest.IsZero() { + wait = c.nextEarliest.Sub(c.now()) + } + c.mu.Unlock() + if wait <= 0 { + return nil + } + return c.Sleep(ctx, wait) +} + +func (c *Client) doOnce(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("transport: build request: %w", err) + } + req.Header.Set("Content-Type", "text/xml; charset=utf-8") + req.Header.Set("SOAPAction", "") + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept-Encoding", "gzip") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &retryableError{err: fmt.Errorf("transport: post %s: %w", endpoint, err)} + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &retryableError{err: fmt.Errorf("transport: read body: %w", err)} + } + + if resp.StatusCode >= 500 { + return nil, &retryableError{err: fmt.Errorf("transport: %s returned %s", endpoint, resp.Status)} + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("transport: %s returned %s", endpoint, resp.Status) + } + return respBody, nil +} + +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// retryableError marks an error returned by doOnce as eligible for retry. +type retryableError struct{ err error } + +func (e *retryableError) Error() string { return e.err.Error() } +func (e *retryableError) Unwrap() error { return e.err } + +func ctxSleep(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go new file mode 100644 index 0000000..97ec78b --- /dev/null +++ b/internal/transport/client_test.go @@ -0,0 +1,276 @@ +package transport_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/chmmou/kasapi-cli/internal/transport" +) + +const sampleEnvelope = `` + +// fakeClock returns a Now func backed by a manually advanced timestamp +// and a Sleep func that records every requested wait. +type fakeClock struct { + mu sync.Mutex + now time.Time + naps []time.Duration +} + +func newFakeClock() *fakeClock { + return &fakeClock{now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} +} + +func (f *fakeClock) Now() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.now +} + +func (f *fakeClock) Sleep(_ context.Context, d time.Duration) error { + f.mu.Lock() + defer f.mu.Unlock() + f.naps = append(f.naps, d) + f.now = f.now.Add(d) + return nil +} + +func (f *fakeClock) Naps() []time.Duration { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]time.Duration, len(f.naps)) + copy(out, f.naps) + return out +} + +func newClient(srv *httptest.Server, fc *fakeClock) *transport.Client { + c := transport.New() + c.HTTPClient = srv.Client() + c.Now = fc.Now + c.Sleep = fc.Sleep + return c +} + +func TestDoSuccess(t *testing.T) { + var got struct { + method string + contentType string + userAgent string + body []byte + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.contentType = r.Header.Get("Content-Type") + got.userAgent = r.Header.Get("User-Agent") + got.body, _ = io.ReadAll(r.Body) + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + resp, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if string(resp) != "" { + t.Errorf("body = %q, want ", resp) + } + if got.method != http.MethodPost { + t.Errorf("method = %q, want POST", got.method) + } + if got.contentType != "text/xml; charset=utf-8" { + t.Errorf("content-type = %q", got.contentType) + } + if got.userAgent == "" || got.userAgent[:11] != "kasapi-cli/" { + t.Errorf("user-agent = %q, want kasapi-cli/...", got.userAgent) + } + if string(got.body) != sampleEnvelope { + t.Errorf("server received %q, want %q", got.body, sampleEnvelope) + } +} + +func TestDoRetriesOn5xx(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n < 3 { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + fc := newFakeClock() + c := newClient(srv, fc) + _, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if calls.Load() != 3 { + t.Errorf("calls = %d, want 3", calls.Load()) + } + naps := fc.Naps() + if len(naps) != 2 { + t.Fatalf("naps = %v, want 2 backoffs", naps) + } + if naps[0] != 500*time.Millisecond || naps[1] != time.Second { + t.Errorf("naps = %v, want [500ms 1s]", naps) + } +} + +func TestDoStopsRetryAfterMax(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + c.MaxRetries = 2 + _, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err == nil { + t.Fatal("expected error after max retries") + } + if calls.Load() != 3 { + t.Errorf("calls = %d, want 3 (1 + 2 retries)", calls.Load()) + } +} + +func TestDoNoRetryOn4xx(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "bad", http.StatusBadRequest) + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + _, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err == nil { + t.Fatal("expected error on 4xx") + } + if calls.Load() != 1 { + t.Errorf("calls = %d, want 1 (no retry)", calls.Load()) + } +} + +func TestDoRetriesOnNetworkError(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n == 1 { + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("ResponseWriter does not support hijack") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatalf("hijack: %v", err) + } + _ = conn.Close() + return + } + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + _, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if err != nil { + t.Fatalf("Do: %v", err) + } + if calls.Load() < 2 { + t.Errorf("calls = %d, want >=2", calls.Load()) + } +} + +func TestDoContextCancelDuringBackoff(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + c := transport.New() + c.HTTPClient = srv.Client() + c.Sleep = func(ctx context.Context, _ time.Duration) error { + return context.Canceled + } + c.Now = time.Now + _, err := c.Do(context.Background(), srv.URL, []byte(sampleEnvelope)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestRecordDelayGatesNextCall(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + fc := newFakeClock() + c := newClient(srv, fc) + c.RecordDelay(500 * time.Millisecond) + + if _, err := c.Do(context.Background(), srv.URL, nil); err != nil { + t.Fatalf("Do: %v", err) + } + naps := fc.Naps() + if len(naps) != 1 || naps[0] != 500*time.Millisecond { + t.Errorf("naps = %v, want [500ms]", naps) + } + + // A second Do without recording a fresh delay must not sleep again. + if _, err := c.Do(context.Background(), srv.URL, nil); err != nil { + t.Fatalf("Do: %v", err) + } + if got := len(fc.Naps()); got != 1 { + t.Errorf("naps after second call = %d, want 1", got) + } +} + +func TestRecordDelayZeroClears(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "") + })) + defer srv.Close() + + fc := newFakeClock() + c := newClient(srv, fc) + c.RecordDelay(time.Second) + c.RecordDelay(0) + + if _, err := c.Do(context.Background(), srv.URL, nil); err != nil { + t.Fatalf("Do: %v", err) + } + if naps := fc.Naps(); len(naps) != 0 { + t.Errorf("naps = %v, want none after RecordDelay(0)", naps) + } +} + +func TestDoRespectsContextDeadlineDuringRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + c := newClient(srv, newFakeClock()) + c.MaxRetries = 0 + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := c.Do(ctx, srv.URL, nil) + if err == nil { + t.Fatal("expected error on cancelled context") + } +} diff --git a/internal/transport/doc.go b/internal/transport/doc.go index e01bc15..8bfee63 100644 --- a/internal/transport/doc.go +++ b/internal/transport/doc.go @@ -1,3 +1,14 @@ -// Package transport provides the HTTP client that wraps the SOAP codec and -// enforces KasFloodDelay between successive calls. See issue #4. +// Package transport provides the HTTP client that posts SOAP envelopes +// to a KAS endpoint. The client gates outgoing requests behind a +// per-instance "earliest next call" timestamp so callers can honour +// the server-side KasFloodDelay returned by every KasApi response. +// +// Decoding is left to the caller; transport returns the raw response +// body. After parsing a response, the caller reports the new delay +// via Client.RecordDelay so the next call to Do is gated correctly. +// +// Network errors and 5xx responses are retried with exponential +// backoff; 4xx and SOAP-level faults are returned without retry. +// +// See issue #4. package transport