feat(infra): Phase 2 — API client abstraction, retry logic, rate limiting
Extract shared APIClient from HCloudClient/HRobotClient/CloudNSClient with configurable retry (exponential backoff + jitter), 429 rate-limit handling (Retry-After header), and functional options (WithHTTPClient, WithRetry, WithAuth, WithPrefix). 30 new client_test.go tests covering retry exhaustion, rate-limit queuing, context cancellation, and integration with all 3 providers. DigitalOcean: no code existed, removed stale doc references. 66 infra tests pass, race-clean. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
c8615ce61e
commit
50ad540241
9 changed files with 1230 additions and 404 deletions
|
|
@ -9,7 +9,7 @@ Infrastructure management, build automation, and release pipelines. ~29K LOC acr
|
|||
- **Ansible engine** — Native Go playbook executor (not shelling out to `ansible-playbook`). SSH, modules, facts, handlers.
|
||||
- **Build system** — Plugin-based builders (Go, Wails, Docker, C++, LinuxKit, Taskfile). Cross-compilation, code signing (macOS/GPG/Windows).
|
||||
- **Release automation** — Version detection, changelog from git history, multi-target publishing (GitHub, Docker, Homebrew, AUR, Scoop, Chocolatey, npm).
|
||||
- **Infrastructure APIs** — Hetzner Cloud, Hetzner Robot (bare metal), CloudNS DNS, DigitalOcean.
|
||||
- **Infrastructure APIs** — Hetzner Cloud, Hetzner Robot (bare metal), CloudNS DNS.
|
||||
- **Container/VM management** — LinuxKit images on QEMU (Linux) or Hyperkit (macOS).
|
||||
- **SDK generation** — OpenAPI spec parsing, TypeScript/Python/Go/PHP client generation, breaking change detection.
|
||||
- **Developer toolkit** — Code quality metrics, TODO detection, coverage reports, dependency graphs.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Extracted from `core/go` on 16 Feb 2026 (commit `392ad68`). Single extraction co
|
|||
|
||||
3. **Python 3.13 embedded** — `deploy/python/` embeds a full Python runtime via kluctl/go-embed-python. Used exclusively for Coolify API client (Python Swagger). Consider replacing with native Go HTTP client to remove the 50MB+ Python dependency.
|
||||
|
||||
4. **DigitalOcean gap** — Referenced in `infra/config.go` types but no `digitalocean.go` implementation exists. Either implement or remove the dead types.
|
||||
4. **DigitalOcean gap** — Was referenced in documentation but no types or implementation existed in code. Removed stale documentation references. No dead types to clean up.
|
||||
|
||||
5. **Single-commit repo** — Entire codebase arrived in one `feat: extract` commit. No git history for individual components. This makes blame/bisect impossible for bugs originating before extraction.
|
||||
|
||||
|
|
|
|||
8
TODO.md
8
TODO.md
|
|
@ -45,10 +45,10 @@ Dispatched from core/go orchestration. Pick up tasks in order.
|
|||
|
||||
## Phase 2: Infrastructure API Robustness
|
||||
|
||||
- [ ] **Retry logic** — Add configurable retry with exponential backoff for Hetzner Cloud/Robot and CloudNS API calls. Cloud APIs are flaky.
|
||||
- [ ] **Rate limiting** — Hetzner Cloud has rate limits. Detect 429 responses, queue and retry.
|
||||
- [ ] **DigitalOcean support** — Currently referenced in config but no implementation. Either implement or remove.
|
||||
- [ ] **API client abstraction** — Extract common HTTP client pattern from hetzner.go and cloudns.go into shared infra client.
|
||||
- [x] **API client abstraction** — Extracted shared `APIClient` struct in `infra/client.go` with `Do()` (JSON) and `DoRaw()` (bytes) methods. `HCloudClient`, `HRobotClient`, and `CloudNSClient` now delegate to `APIClient` via configurable auth functions and error prefixes. Options: `WithHTTPClient`, `WithRetry`, `WithAuth`, `WithPrefix`. 30 new `client_test.go` tests.
|
||||
- [x] **Retry logic** — `APIClient` implements configurable exponential backoff with jitter. Retries on 5xx and transport errors; does NOT retry 4xx (except 429). `RetryConfig{MaxRetries, InitialBackoff, MaxBackoff}` with `DefaultRetryConfig()` (3 retries, 100ms, 5s). Context cancellation respected during backoff.
|
||||
- [x] **Rate limiting** — Detects 429 responses, parses `Retry-After` header (seconds format, falls back to 1s). Queues subsequent requests behind a shared `blockedUntil` mutex. Rate limit wait is per-`APIClient` instance. Tested with real 1s Retry-After delays.
|
||||
- [x] **DigitalOcean support** — Investigated: no types or implementation existed in `infra/` code, only stale documentation references in CLAUDE.md and FINDINGS.md. Removed the dead references. No code changes needed.
|
||||
|
||||
## Phase 3: Release Pipeline Testing
|
||||
|
||||
|
|
|
|||
274
infra/client.go
Normal file
274
infra/client.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig controls exponential backoff retry behaviour.
|
||||
type RetryConfig struct {
|
||||
// MaxRetries is the maximum number of retry attempts (0 = no retries).
|
||||
MaxRetries int
|
||||
// InitialBackoff is the delay before the first retry.
|
||||
InitialBackoff time.Duration
|
||||
// MaxBackoff is the upper bound on backoff duration.
|
||||
MaxBackoff time.Duration
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns sensible defaults: 3 retries, 100ms initial, 5s max.
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 100 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// APIClient is a shared HTTP client with retry, rate-limit handling,
|
||||
// and configurable authentication. Provider-specific clients embed or
|
||||
// delegate to this struct.
|
||||
type APIClient struct {
|
||||
client *http.Client
|
||||
retry RetryConfig
|
||||
authFn func(req *http.Request)
|
||||
prefix string // error prefix, e.g. "hcloud API"
|
||||
mu sync.Mutex
|
||||
blockedUntil time.Time // rate-limit window
|
||||
}
|
||||
|
||||
// APIClientOption configures an APIClient.
|
||||
type APIClientOption func(*APIClient)
|
||||
|
||||
// WithHTTPClient sets a custom http.Client.
|
||||
func WithHTTPClient(c *http.Client) APIClientOption {
|
||||
return func(a *APIClient) { a.client = c }
|
||||
}
|
||||
|
||||
// WithRetry sets the retry configuration.
|
||||
func WithRetry(cfg RetryConfig) APIClientOption {
|
||||
return func(a *APIClient) { a.retry = cfg }
|
||||
}
|
||||
|
||||
// WithAuth sets the authentication function applied to every request.
|
||||
func WithAuth(fn func(req *http.Request)) APIClientOption {
|
||||
return func(a *APIClient) { a.authFn = fn }
|
||||
}
|
||||
|
||||
// WithPrefix sets the error message prefix (e.g. "hcloud API").
|
||||
func WithPrefix(p string) APIClientOption {
|
||||
return func(a *APIClient) { a.prefix = p }
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new APIClient with the given options.
|
||||
func NewAPIClient(opts ...APIClientOption) *APIClient {
|
||||
a := &APIClient{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
retry: DefaultRetryConfig(),
|
||||
prefix: "api",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with authentication, retry logic, and
|
||||
// rate-limit handling. If result is non-nil, the response body is
|
||||
// JSON-decoded into it.
|
||||
func (a *APIClient) Do(req *http.Request, result any) error {
|
||||
if a.authFn != nil {
|
||||
a.authFn(req)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
attempts := 1 + a.retry.MaxRetries
|
||||
|
||||
for attempt := range attempts {
|
||||
// Respect rate-limit backoff window.
|
||||
a.mu.Lock()
|
||||
wait := time.Until(a.blockedUntil)
|
||||
a.mu.Unlock()
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return req.Context().Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("%s: %w", a.prefix, err)
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("read response: %w", err)
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Rate-limited: honour Retry-After and retry.
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||||
a.mu.Lock()
|
||||
a.blockedUntil = time.Now().Add(retryAfter)
|
||||
a.mu.Unlock()
|
||||
|
||||
lastErr = fmt.Errorf("%s %d: rate limited", a.prefix, resp.StatusCode)
|
||||
if attempt < attempts-1 {
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return req.Context().Err()
|
||||
case <-time.After(retryAfter):
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Server errors are retryable.
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data))
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Client errors (4xx, except 429 handled above) are not retried.
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
// Success — decode if requested.
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// DoRaw executes a request and returns the raw response body.
|
||||
// Same retry/rate-limit logic as Do but without JSON decoding.
|
||||
func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||
if a.authFn != nil {
|
||||
a.authFn(req)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
attempts := 1 + a.retry.MaxRetries
|
||||
|
||||
for attempt := range attempts {
|
||||
// Respect rate-limit backoff window.
|
||||
a.mu.Lock()
|
||||
wait := time.Until(a.blockedUntil)
|
||||
a.mu.Unlock()
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("%s: %w", a.prefix, err)
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("read response: %w", err)
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||||
a.mu.Lock()
|
||||
a.blockedUntil = time.Now().Add(retryAfter)
|
||||
a.mu.Unlock()
|
||||
|
||||
lastErr = fmt.Errorf("%s %d: rate limited", a.prefix, resp.StatusCode)
|
||||
if attempt < attempts-1 {
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
case <-time.After(retryAfter):
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data))
|
||||
if attempt < attempts-1 {
|
||||
a.backoff(attempt, req)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// backoff sleeps for exponential backoff with jitter, respecting context cancellation.
|
||||
func (a *APIClient) backoff(attempt int, req *http.Request) {
|
||||
base := float64(a.retry.InitialBackoff) * math.Pow(2, float64(attempt))
|
||||
if base > float64(a.retry.MaxBackoff) {
|
||||
base = float64(a.retry.MaxBackoff)
|
||||
}
|
||||
// Add jitter: 50-100% of calculated backoff
|
||||
jitter := base * (0.5 + rand.Float64()*0.5)
|
||||
d := time.Duration(jitter)
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// parseRetryAfter interprets the Retry-After header value.
|
||||
// Supports seconds (integer) format. Falls back to 1 second.
|
||||
func parseRetryAfter(val string) time.Duration {
|
||||
if val == "" {
|
||||
return 1 * time.Second
|
||||
}
|
||||
seconds, err := strconv.Atoi(val)
|
||||
if err == nil && seconds > 0 {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
// Could also try HTTP-date format here, but seconds is typical for APIs.
|
||||
return 1 * time.Second
|
||||
}
|
||||
740
infra/client_test.go
Normal file
740
infra/client_test.go
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Constructor ---
|
||||
|
||||
func TestNewAPIClient_Good_Defaults(t *testing.T) {
|
||||
c := NewAPIClient()
|
||||
assert.NotNil(t, c.client)
|
||||
assert.Equal(t, "api", c.prefix)
|
||||
assert.Equal(t, 3, c.retry.MaxRetries)
|
||||
assert.Equal(t, 100*time.Millisecond, c.retry.InitialBackoff)
|
||||
assert.Equal(t, 5*time.Second, c.retry.MaxBackoff)
|
||||
assert.Nil(t, c.authFn)
|
||||
}
|
||||
|
||||
func TestNewAPIClient_Good_WithOptions(t *testing.T) {
|
||||
custom := &http.Client{Timeout: 10 * time.Second}
|
||||
authCalled := false
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(custom),
|
||||
WithPrefix("test-api"),
|
||||
WithRetry(RetryConfig{MaxRetries: 5, InitialBackoff: 200 * time.Millisecond, MaxBackoff: 10 * time.Second}),
|
||||
WithAuth(func(req *http.Request) { authCalled = true }),
|
||||
)
|
||||
|
||||
assert.Equal(t, custom, c.client)
|
||||
assert.Equal(t, "test-api", c.prefix)
|
||||
assert.Equal(t, 5, c.retry.MaxRetries)
|
||||
assert.Equal(t, 200*time.Millisecond, c.retry.InitialBackoff)
|
||||
assert.Equal(t, 10*time.Second, c.retry.MaxBackoff)
|
||||
|
||||
// Trigger auth
|
||||
c.authFn(&http.Request{Header: http.Header{}})
|
||||
assert.True(t, authCalled)
|
||||
}
|
||||
|
||||
func TestDefaultRetryConfig_Good(t *testing.T) {
|
||||
cfg := DefaultRetryConfig()
|
||||
assert.Equal(t, 3, cfg.MaxRetries)
|
||||
assert.Equal(t, 100*time.Millisecond, cfg.InitialBackoff)
|
||||
assert.Equal(t, 5*time.Second, cfg.MaxBackoff)
|
||||
}
|
||||
|
||||
// --- Do method ---
|
||||
|
||||
func TestAPIClient_Do_Good_Success(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"test"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err = c.Do(req, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", result.Name)
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_NilResult(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodDelete, ts.URL+"/item", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_AuthApplied(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer my-token")
|
||||
}),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_ClientError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`not found`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("test-api"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/missing", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "test-api 404")
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_DecodeError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct{ Name string }
|
||||
err = c.Do(req, &result)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode response")
|
||||
}
|
||||
|
||||
// --- Retry logic ---
|
||||
|
||||
func TestAPIClient_Do_Good_RetriesServerError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`server error`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("retry-test"),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 10 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
err = c.Do(req, &result)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, int32(3), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`always fails`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("exhaust-test"),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exhaust-test 500")
|
||||
// 1 initial + 2 retries = 3 attempts
|
||||
assert.Equal(t, int32(3), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_NoRetryOn4xx(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`bad request`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
// 4xx errors are NOT retried
|
||||
assert.Equal(t, int32(1), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_ZeroRetries(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`fail`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{}), // Zero retries
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, int32(1), attempts.Load())
|
||||
}
|
||||
|
||||
// --- Rate limiting ---
|
||||
|
||||
func TestAPIClient_Do_Good_RateLimitRetry(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`rate limited`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
err = c.Do(req, &result)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
// Should have waited at least 1 second for Retry-After
|
||||
assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900))
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_RateLimitExhausted(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`rate limited`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 1,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rate limited")
|
||||
assert.Equal(t, int32(2), attempts.Load()) // 1 initial + 1 retry
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_RateLimitNoRetryAfterHeader(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
// 429 without Retry-After header — falls back to 1s
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`rate limited`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 1,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Ugly_ContextCancelled(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`fail`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 5,
|
||||
InitialBackoff: 5 * time.Second, // long backoff
|
||||
MaxBackoff: 10 * time.Second,
|
||||
}),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Do(req, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- DoRaw method ---
|
||||
|
||||
func TestAPIClient_DoRaw_Good_Success(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`raw data here`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/data", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := c.DoRaw(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "raw data here", string(data))
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Good_AuthApplied(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user", user)
|
||||
assert.Equal(t, "pass", pass)
|
||||
_, _ = w.Write([]byte(`ok`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) { req.SetBasicAuth("user", "pass") }),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := c.DoRaw(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", string(data))
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Bad_ClientError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`forbidden`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("raw-test"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/secret", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = c.DoRaw(req)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "raw-test 403")
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n < 2 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`bad gateway`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`ok`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := c.DoRaw(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", string(data))
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Good_RateLimitRetry(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`rate limited`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`ok`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := c.DoRaw(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", string(data))
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Bad_NoRetryOn4xx(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`validation error`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = c.DoRaw(req)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, int32(1), attempts.Load())
|
||||
}
|
||||
|
||||
// --- parseRetryAfter ---
|
||||
|
||||
func TestParseRetryAfter_Good_Seconds(t *testing.T) {
|
||||
d := parseRetryAfter("5")
|
||||
assert.Equal(t, 5*time.Second, d)
|
||||
}
|
||||
|
||||
func TestParseRetryAfter_Good_EmptyDefault(t *testing.T) {
|
||||
d := parseRetryAfter("")
|
||||
assert.Equal(t, 1*time.Second, d)
|
||||
}
|
||||
|
||||
func TestParseRetryAfter_Bad_InvalidFallback(t *testing.T) {
|
||||
d := parseRetryAfter("not-a-number")
|
||||
assert.Equal(t, 1*time.Second, d)
|
||||
}
|
||||
|
||||
func TestParseRetryAfter_Good_Zero(t *testing.T) {
|
||||
d := parseRetryAfter("0")
|
||||
// 0 is not > 0, falls back to 1s
|
||||
assert.Equal(t, 1*time.Second, d)
|
||||
}
|
||||
|
||||
// --- Integration: HCloudClient uses APIClient retry ---
|
||||
|
||||
func TestHCloudClient_Good_RetriesOnServerError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n < 2 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`unavailable`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"servers":[]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
servers, err := client.ListServers(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, servers)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestHCloudClient_Good_HandlesRateLimit(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`rate limited`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"servers":[]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
servers, err := client.ListServers(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, servers)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
// --- Integration: CloudNS uses APIClient retry ---
|
||||
|
||||
func TestCloudNSClient_Good_RetriesOnServerError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n < 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`internal error`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"example.com","type":"master","zone":"domain","status":"1"}]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
zones, err := client.ListZones(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, zones, 1)
|
||||
assert.Equal(t, "example.com", zones[0].Name)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
// --- Rate limit shared state ---
|
||||
|
||||
func TestAPIClient_Good_RateLimitSharedState(t *testing.T) {
|
||||
// Verify that the blockedUntil state is respected across requests
|
||||
var attempts atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`ok`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithRetry(RetryConfig{
|
||||
MaxRetries: 1,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/first", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := c.DoRaw(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", string(data))
|
||||
}
|
||||
|
|
@ -4,11 +4,9 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cloudnsBaseURL = "https://api.cloudns.net"
|
||||
|
|
@ -17,7 +15,8 @@ const cloudnsBaseURL = "https://api.cloudns.net"
|
|||
type CloudNSClient struct {
|
||||
authID string
|
||||
password string
|
||||
client *http.Client
|
||||
baseURL string
|
||||
api *APIClient
|
||||
}
|
||||
|
||||
// NewCloudNSClient creates a new CloudNS API client.
|
||||
|
|
@ -26,9 +25,8 @@ func NewCloudNSClient(authID, password string) *CloudNSClient {
|
|||
return &CloudNSClient{
|
||||
authID: authID,
|
||||
password: password,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: cloudnsBaseURL,
|
||||
api: NewAPIClient(WithPrefix("cloudns API")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +233,7 @@ func (c *CloudNSClient) authParams() url.Values {
|
|||
}
|
||||
|
||||
func (c *CloudNSClient) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
u := cloudnsBaseURL + path + "?" + params.Encode()
|
||||
u := c.baseURL + path + "?" + params.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -244,7 +242,7 @@ func (c *CloudNSClient) get(ctx context.Context, path string, params url.Values)
|
|||
}
|
||||
|
||||
func (c *CloudNSClient) post(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudnsBaseURL+path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -253,20 +251,5 @@ func (c *CloudNSClient) post(ctx context.Context, path string, params url.Values
|
|||
}
|
||||
|
||||
func (c *CloudNSClient) doRaw(req *http.Request) ([]byte, error) {
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cloudns API: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("cloudns API %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return c.api.DoRaw(req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func TestNewCloudNSClient_Good(t *testing.T) {
|
|||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "12345", c.authID)
|
||||
assert.Equal(t, "secret", c.password)
|
||||
assert.NotNil(t, c.client)
|
||||
assert.NotNil(t, c.api)
|
||||
}
|
||||
|
||||
// --- authParams ---
|
||||
|
|
@ -40,11 +40,13 @@ func TestCloudNSClient_DoRaw_Good_ReturnsBody(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "test",
|
||||
password: "test",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("test", "test")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil)
|
||||
|
|
@ -62,11 +64,13 @@ func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "bad",
|
||||
password: "creds",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("bad", "creds")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil)
|
||||
|
|
@ -84,11 +88,13 @@ func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "test",
|
||||
password: "test",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("test", "test")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil)
|
||||
|
|
@ -199,7 +205,6 @@ func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
|||
// --- CreateRecord response parsing ---
|
||||
|
||||
func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
|
||||
// Verify the response shape CreateRecord expects
|
||||
data := `{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":54321}}`
|
||||
|
||||
var result struct {
|
||||
|
|
@ -217,7 +222,6 @@ func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) {
|
||||
// Verify non-Success status produces an error message
|
||||
data := `{"status":"Failed","statusDescription":"Record already exists."}`
|
||||
|
||||
var result struct {
|
||||
|
|
@ -250,7 +254,6 @@ func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) {
|
|||
|
||||
func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify auth params are passed
|
||||
assert.NotEmpty(t, r.URL.Query().Get("auth-id"))
|
||||
assert.NotEmpty(t, r.URL.Query().Get("auth-password"))
|
||||
|
||||
|
|
@ -259,27 +262,15 @@ func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "12345",
|
||||
password: "secret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
// Build a request similar to what get() would build, but pointing at test server
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
params.Set("page", "1")
|
||||
params.Set("rows-per-page", "100")
|
||||
params.Set("search", "")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/list-zones.json?"+params.Encode(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := client.doRaw(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var zones []CloudNSZone
|
||||
err = json.Unmarshal(data, &zones)
|
||||
zones, err := client.ListZones(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, zones, 1)
|
||||
assert.Equal(t, "example.com", zones[0].Name)
|
||||
|
|
@ -297,24 +288,15 @@ func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "12345",
|
||||
password: "secret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
params.Set("domain-name", "example.com")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/records.json?"+params.Encode(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := client.doRaw(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var records map[string]CloudNSRecord
|
||||
err = json.Unmarshal(data, &records)
|
||||
records, err := client.ListRecords(context.Background(), "example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 2)
|
||||
assert.Equal(t, "A", records["1"].Type)
|
||||
|
|
@ -335,37 +317,17 @@ func TestCloudNSClient_CreateRecord_Good_ViaDoRaw(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "12345",
|
||||
password: "secret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
params.Set("domain-name", "example.com")
|
||||
params.Set("host", "www")
|
||||
params.Set("record-type", "A")
|
||||
params.Set("record", "1.2.3.4")
|
||||
params.Set("ttl", "3600")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/add-record.json", nil)
|
||||
id, err := client.CreateRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600)
|
||||
require.NoError(t, err)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
data, err := client.doRaw(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Success", result.Status)
|
||||
assert.Equal(t, 99, result.Data.ID)
|
||||
assert.Equal(t, "99", id)
|
||||
}
|
||||
|
||||
func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) {
|
||||
|
|
@ -379,37 +341,21 @@ func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "12345",
|
||||
password: "secret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
params.Set("domain-name", "example.com")
|
||||
params.Set("record-id", "42")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/delete-record.json", nil)
|
||||
err := client.DeleteRecord(context.Background(), "example.com", "42")
|
||||
require.NoError(t, err)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
data, err := client.doRaw(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Success", result.Status)
|
||||
}
|
||||
|
||||
// --- ACME challenge helpers ---
|
||||
|
||||
func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
|
||||
// SetACMEChallenge delegates to CreateRecord with specific params.
|
||||
// Verify the delegation shape by checking the expected call.
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host"))
|
||||
|
|
@ -421,43 +367,20 @@ func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "12345",
|
||||
password: "secret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("12345", "secret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
// Build request matching what SetACMEChallenge -> CreateRecord -> post() builds
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
params.Set("domain-name", "example.com")
|
||||
params.Set("host", "_acme-challenge")
|
||||
params.Set("record-type", "TXT")
|
||||
params.Set("record", "acme-token-value")
|
||||
params.Set("ttl", "60")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/add-record.json", nil)
|
||||
id, err := client.SetACMEChallenge(context.Background(), "example.com", "acme-token-value")
|
||||
require.NoError(t, err)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
data, err := client.doRaw(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Success", result.Status)
|
||||
assert.Equal(t, 777, result.Data.ID)
|
||||
assert.Equal(t, "777", id)
|
||||
}
|
||||
|
||||
func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
||||
// ClearACMEChallenge lists records, finds _acme-challenge TXT records, deletes them.
|
||||
// Test the logic by verifying the record filtering.
|
||||
records := map[string]CloudNSRecord{
|
||||
"1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
"2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"},
|
||||
|
|
@ -465,7 +388,6 @@ func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
|||
"4": {ID: "4", Type: "TXT", Host: "_acme-challenge", Record: "token2"},
|
||||
}
|
||||
|
||||
// Simulate the filtering logic from ClearACMEChallenge
|
||||
var toDelete []string
|
||||
for id, r := range records {
|
||||
if r.Host == "_acme-challenge" && r.Type == "TXT" {
|
||||
|
|
@ -481,7 +403,6 @@ func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
|||
// --- EnsureRecord logic ---
|
||||
|
||||
func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
||||
// Simulate the check: host matches, type matches, value matches => no change
|
||||
records := map[string]CloudNSRecord{
|
||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
}
|
||||
|
|
@ -494,7 +415,6 @@ func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
|||
for _, r := range records {
|
||||
if r.Host == host && r.Type == recordType {
|
||||
if r.Record == value {
|
||||
// Already correct — no change needed
|
||||
needsUpdate = false
|
||||
needsCreate = false
|
||||
} else {
|
||||
|
|
@ -505,7 +425,6 @@ func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
|||
}
|
||||
|
||||
if !needsUpdate {
|
||||
// Check if we found any match at all
|
||||
found := false
|
||||
for _, r := range records {
|
||||
if r.Host == host && r.Type == recordType {
|
||||
|
|
@ -529,7 +448,7 @@ func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) {
|
|||
|
||||
host := "www"
|
||||
recordType := "A"
|
||||
value := "5.6.7.8" // Different value
|
||||
value := "5.6.7.8"
|
||||
|
||||
var needsUpdate bool
|
||||
for _, r := range records {
|
||||
|
|
@ -549,7 +468,7 @@ func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
|
|||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
}
|
||||
|
||||
host := "api" // Does not exist
|
||||
host := "api"
|
||||
recordType := "A"
|
||||
|
||||
found := false
|
||||
|
|
@ -568,15 +487,16 @@ func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
|
|||
func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Empty body
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "test",
|
||||
password: "test",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("test", "test")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil)
|
||||
|
|
@ -588,7 +508,6 @@ func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
|
||||
// An empty record set is a valid empty map
|
||||
data := `{}`
|
||||
|
||||
var records map[string]CloudNSRecord
|
||||
|
|
@ -599,7 +518,6 @@ func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) {
|
||||
// Verify that auth params make it to the server in the query string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))
|
||||
assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password"))
|
||||
|
|
@ -609,11 +527,13 @@ func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &CloudNSClient{
|
||||
authID: "49500",
|
||||
password: "supersecret",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewCloudNSClient("49500", "supersecret")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("cloudns API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
params := client.authParams()
|
||||
|
|
|
|||
103
infra/hetzner.go
103
infra/hetzner.go
|
|
@ -4,10 +4,8 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -17,18 +15,24 @@ const (
|
|||
|
||||
// HCloudClient is an HTTP client for the Hetzner Cloud API.
|
||||
type HCloudClient struct {
|
||||
token string
|
||||
client *http.Client
|
||||
token string
|
||||
baseURL string
|
||||
api *APIClient
|
||||
}
|
||||
|
||||
// NewHCloudClient creates a new Hetzner Cloud API client.
|
||||
func NewHCloudClient(token string) *HCloudClient {
|
||||
return &HCloudClient{
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
c := &HCloudClient{
|
||||
token: token,
|
||||
baseURL: hcloudBaseURL,
|
||||
}
|
||||
c.api = NewAPIClient(
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
)
|
||||
return c
|
||||
}
|
||||
|
||||
// HCloudServer represents a Hetzner Cloud server.
|
||||
|
|
@ -233,7 +237,7 @@ func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, descrip
|
|||
}
|
||||
|
||||
func (c *HCloudClient) get(ctx context.Context, path string, result any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hcloudBaseURL+path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -241,7 +245,7 @@ func (c *HCloudClient) get(ctx context.Context, path string, result any) error {
|
|||
}
|
||||
|
||||
func (c *HCloudClient) post(ctx context.Context, path string, body []byte, result any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hcloudBaseURL+path, strings.NewReader(string(body)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -250,7 +254,7 @@ func (c *HCloudClient) post(ctx context.Context, path string, body []byte, resul
|
|||
}
|
||||
|
||||
func (c *HCloudClient) delete(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, hcloudBaseURL+path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -258,38 +262,7 @@ func (c *HCloudClient) delete(ctx context.Context, path string) error {
|
|||
}
|
||||
|
||||
func (c *HCloudClient) do(req *http.Request, result any) error {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hcloud API: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var apiErr struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &apiErr) == nil && apiErr.Error.Message != "" {
|
||||
return fmt.Errorf("hcloud API %d: %s — %s", resp.StatusCode, apiErr.Error.Code, apiErr.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("hcloud API %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.api.Do(req, result)
|
||||
}
|
||||
|
||||
// --- Hetzner Robot API ---
|
||||
|
|
@ -298,18 +271,24 @@ func (c *HCloudClient) do(req *http.Request, result any) error {
|
|||
type HRobotClient struct {
|
||||
user string
|
||||
password string
|
||||
client *http.Client
|
||||
baseURL string
|
||||
api *APIClient
|
||||
}
|
||||
|
||||
// NewHRobotClient creates a new Hetzner Robot API client.
|
||||
func NewHRobotClient(user, password string) *HRobotClient {
|
||||
return &HRobotClient{
|
||||
c := &HRobotClient{
|
||||
user: user,
|
||||
password: password,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: hrobotBaseURL,
|
||||
}
|
||||
c.api = NewAPIClient(
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.SetBasicAuth(c.user, c.password)
|
||||
}),
|
||||
WithPrefix("hrobot API"),
|
||||
)
|
||||
return c
|
||||
}
|
||||
|
||||
// HRobotServer represents a Hetzner Robot dedicated server.
|
||||
|
|
@ -351,31 +330,9 @@ func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer,
|
|||
}
|
||||
|
||||
func (c *HRobotClient) get(ctx context.Context, path string, result any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hrobotBaseURL+path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.user, c.password)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hrobot API: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("hrobot API %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.api.Do(req, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,25 +11,11 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestHCloudClient creates a HCloudClient pointing at the given test server.
|
||||
func newTestHCloudClient(t *testing.T, handler http.Handler) (*HCloudClient, *httptest.Server) {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(handler)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
client := NewHCloudClient("test-token")
|
||||
// Override the base URL by replacing the client's HTTP client transport
|
||||
// to rewrite requests to our test server.
|
||||
client.client = ts.Client()
|
||||
|
||||
return client, ts
|
||||
}
|
||||
|
||||
func TestNewHCloudClient_Good(t *testing.T) {
|
||||
c := NewHCloudClient("my-token")
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "my-token", c.token)
|
||||
assert.NotNil(t, c.client)
|
||||
assert.NotNil(t, c.api)
|
||||
}
|
||||
|
||||
func TestHCloudClient_ListServers_Good(t *testing.T) {
|
||||
|
|
@ -41,38 +27,16 @@ func TestHCloudClient_ListServers_Good(t *testing.T) {
|
|||
resp := map[string]any{
|
||||
"servers": []map[string]any{
|
||||
{
|
||||
"id": 1,
|
||||
"name": "de1",
|
||||
"status": "running",
|
||||
"public_net": map[string]any{
|
||||
"ipv4": map[string]any{"ip": "1.2.3.4"},
|
||||
},
|
||||
"server_type": map[string]any{
|
||||
"name": "cx22",
|
||||
"cores": 2,
|
||||
"memory": 4.0,
|
||||
"disk": 40,
|
||||
},
|
||||
"datacenter": map[string]any{
|
||||
"name": "fsn1-dc14",
|
||||
},
|
||||
"id": 1, "name": "de1", "status": "running",
|
||||
"public_net": map[string]any{"ipv4": map[string]any{"ip": "1.2.3.4"}},
|
||||
"server_type": map[string]any{"name": "cx22", "cores": 2, "memory": 4.0, "disk": 40},
|
||||
"datacenter": map[string]any{"name": "fsn1-dc14"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "de2",
|
||||
"status": "running",
|
||||
"public_net": map[string]any{
|
||||
"ipv4": map[string]any{"ip": "5.6.7.8"},
|
||||
},
|
||||
"server_type": map[string]any{
|
||||
"name": "cx32",
|
||||
"cores": 4,
|
||||
"memory": 8.0,
|
||||
"disk": 80,
|
||||
},
|
||||
"datacenter": map[string]any{
|
||||
"name": "nbg1-dc3",
|
||||
},
|
||||
"id": 2, "name": "de2", "status": "running",
|
||||
"public_net": map[string]any{"ipv4": map[string]any{"ip": "5.6.7.8"}},
|
||||
"server_type": map[string]any{"name": "cx32", "cores": 4, "memory": 8.0, "disk": 80},
|
||||
"datacenter": map[string]any{"name": "nbg1-dc3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -83,27 +47,22 @@ func TestHCloudClient_ListServers_Good(t *testing.T) {
|
|||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
// Create client that points at our test server
|
||||
client := &HCloudClient{
|
||||
token: "test-token",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+client.token)
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{}), // no retries in tests
|
||||
)
|
||||
|
||||
// We need to override the base URL — the simplest approach is to
|
||||
// intercept at the HTTP transport level. Instead, let us call the
|
||||
// low-level get method directly with a known URL.
|
||||
ctx := context.Background()
|
||||
var result struct {
|
||||
Servers []HCloudServer `json:"servers"`
|
||||
}
|
||||
err := client.get(ctx, "/servers", &result)
|
||||
// This will fail because it tries to hit the real hcloud URL, not our test server.
|
||||
// We need a different approach — let the test verify response parsing.
|
||||
if err != nil {
|
||||
t.Skip("cannot intercept hcloud base URL in unit test; skipping HTTP round-trip test")
|
||||
}
|
||||
|
||||
assert.Len(t, result.Servers, 2)
|
||||
servers, err := client.ListServers(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, servers, 2)
|
||||
assert.Equal(t, "de1", servers[0].Name)
|
||||
assert.Equal(t, "de2", servers[1].Name)
|
||||
}
|
||||
|
||||
func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) {
|
||||
|
|
@ -114,19 +73,21 @@ func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HCloudClient{
|
||||
token: "test-token",
|
||||
client: ts.Client(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/servers", nil)
|
||||
require.NoError(t, err)
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
var result struct {
|
||||
Servers []HCloudServer `json:"servers"`
|
||||
}
|
||||
err = client.do(req, &result)
|
||||
err := client.get(context.Background(), "/servers", &result)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Servers, 1)
|
||||
assert.Equal(t, 1, result.Servers[0].ID)
|
||||
|
|
@ -141,21 +102,21 @@ func TestHCloudClient_Do_Bad_APIError(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HCloudClient{
|
||||
token: "bad-token",
|
||||
client: ts.Client(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/servers", nil)
|
||||
require.NoError(t, err)
|
||||
client := NewHCloudClient("bad-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer bad-token")
|
||||
}),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
var result struct{}
|
||||
err = client.do(req, &result)
|
||||
err := client.get(context.Background(), "/servers", &result)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hcloud API 403")
|
||||
assert.Contains(t, err.Error(), "forbidden")
|
||||
assert.Contains(t, err.Error(), "insufficient permissions")
|
||||
}
|
||||
|
||||
func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) {
|
||||
|
|
@ -165,16 +126,15 @@ func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HCloudClient{
|
||||
token: "test-token",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/servers", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.do(req, nil)
|
||||
err := client.get(context.Background(), "/servers", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hcloud API 500")
|
||||
}
|
||||
|
|
@ -185,16 +145,15 @@ func TestHCloudClient_Do_Good_NilResult(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HCloudClient{
|
||||
token: "test-token",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewHCloudClient("test-token")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithPrefix("hcloud API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, ts.URL+"/servers/1", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.do(req, nil)
|
||||
err := client.delete(context.Background(), "/servers/1")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
|
|
@ -205,10 +164,10 @@ func TestNewHRobotClient_Good(t *testing.T) {
|
|||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "user", c.user)
|
||||
assert.Equal(t, "pass", c.password)
|
||||
assert.NotNil(t, c.client)
|
||||
assert.NotNil(t, c.api)
|
||||
}
|
||||
|
||||
func TestHRobotClient_Get_Good(t *testing.T) {
|
||||
func TestHRobotClient_ListServers_Good(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
assert.True(t, ok)
|
||||
|
|
@ -220,28 +179,23 @@ func TestHRobotClient_Get_Good(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HRobotClient{
|
||||
user: "testuser",
|
||||
password: "testpass",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewHRobotClient("testuser", "testpass")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
}),
|
||||
WithPrefix("hrobot API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
var raw []struct {
|
||||
Server HRobotServer `json:"server"`
|
||||
}
|
||||
err := client.get(ctx, ts.URL+"/server", &raw)
|
||||
// This won't work because get() prepends hrobotBaseURL. Test the do layer instead.
|
||||
if err != nil {
|
||||
// Test the parsing directly
|
||||
resp := `[{"server":{"server_ip":"1.2.3.4","server_name":"test","product":"EX44","dc":"FSN1","status":"ready","cancelled":false}}]`
|
||||
err = json.Unmarshal([]byte(resp), &raw)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, raw, 1)
|
||||
assert.Equal(t, "1.2.3.4", raw[0].Server.ServerIP)
|
||||
assert.Equal(t, "test", raw[0].Server.ServerName)
|
||||
assert.Equal(t, "EX44", raw[0].Server.Product)
|
||||
}
|
||||
servers, err := client.ListServers(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, servers, 1)
|
||||
assert.Equal(t, "1.2.3.4", servers[0].ServerIP)
|
||||
assert.Equal(t, "test", servers[0].ServerName)
|
||||
assert.Equal(t, "EX44", servers[0].Product)
|
||||
}
|
||||
|
||||
func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
|
||||
|
|
@ -251,22 +205,20 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
|
|||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &HRobotClient{
|
||||
user: "bad",
|
||||
password: "creds",
|
||||
client: ts.Client(),
|
||||
}
|
||||
client := NewHRobotClient("bad", "creds")
|
||||
client.baseURL = ts.URL
|
||||
client.api = NewAPIClient(
|
||||
WithHTTPClient(ts.Client()),
|
||||
WithAuth(func(req *http.Request) {
|
||||
req.SetBasicAuth("bad", "creds")
|
||||
}),
|
||||
WithPrefix("hrobot API"),
|
||||
WithRetry(RetryConfig{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/server", nil)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth(client.user, client.password)
|
||||
|
||||
resp, err := client.client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
err := client.get(context.Background(), "/server", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hrobot API 401")
|
||||
}
|
||||
|
||||
// --- Type serialisation ---
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue