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