package client import ( "bytes" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/url" "os" "strconv" "strings" "time" "git.db.org.ai/dborg/internal/config" ) type Client struct { config *config.Config httpClient *http.Client } func New(cfg *config.Config) (*Client, error) { if err := cfg.Validate(); err != nil { return nil, err } return newClient(cfg), nil } func NewUnauthenticated(cfg *config.Config) (*Client, error) { return newClient(cfg), nil } func newClient(cfg *config.Config) *Client { return &Client{ config: cfg, httpClient: &http.Client{ Timeout: cfg.Timeout, }, } } func (c *Client) debugf(format string, args ...interface{}) { if !c.config.Debug { return } fmt.Fprintf(os.Stderr, "[dborg] "+format+"\n", args...) } func redactKey(key string) string { if len(key) <= 8 { return "***" } return key[:4] + "..." + key[len(key)-4:] } func isRetryable(statusCode int) bool { return statusCode == http.StatusTooManyRequests || statusCode == http.StatusRequestTimeout || statusCode >= 500 } func backoffDelay(attempt int, retryAfter string) time.Duration { if retryAfter != "" { if secs, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil && secs > 0 { return time.Duration(secs) * time.Second } if t, err := http.ParseTime(retryAfter); err == nil { if d := time.Until(t); d > 0 { return d } } } base := time.Duration(1< 30*time.Second { base = 30 * time.Second } jitter := time.Duration(rand.Int63n(int64(base) / 2)) return base + jitter } func (c *Client) doRequest(method, path string, params url.Values, body interface{}) ([]byte, error) { fullURL := c.config.BaseURL + path if len(params) > 0 { fullURL += "?" + params.Encode() } var bodyBytes []byte if body != nil { var err error bodyBytes, err = json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } } c.debugf("→ %s %s (api_key=%s)", method, fullURL, redactKey(c.config.APIKey)) if len(bodyBytes) > 0 { c.debugf(" body: %s", string(bodyBytes)) } var lastErr error for attempt := 0; attempt <= c.config.MaxRetries; attempt++ { if attempt > 0 { delay := backoffDelay(attempt, "") if lastErr != nil { if ra, ok := retryAfterFromErr(lastErr); ok { delay = backoffDelay(attempt, ra) } } c.debugf(" retry %d/%d after %s (last error: %v)", attempt, c.config.MaxRetries, delay, lastErr) time.Sleep(delay) } var reqBody io.Reader if bodyBytes != nil { reqBody = bytes.NewReader(bodyBytes) } req, err := http.NewRequest(method, fullURL, reqBody) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("X-API-Key", c.config.APIKey) req.Header.Set("User-Agent", c.config.UserAgent) if bodyBytes != nil { req.Header.Set("Content-Type", "application/json") } start := time.Now() resp, err := c.httpClient.Do(req) if err != nil { lastErr = err c.debugf("← network error after %s: %v", time.Since(start), err) continue } respBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() c.debugf("← %d %s (%s, %d bytes)", resp.StatusCode, http.StatusText(resp.StatusCode), time.Since(start), len(respBody)) if readErr != nil { lastErr = fmt.Errorf("failed to read response body: %w", readErr) if !isRetryable(resp.StatusCode) { return nil, lastErr } continue } if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { return respBody, nil } lastErr = httpError(resp.StatusCode, respBody, resp.Header.Get("Retry-After")) if !isRetryable(resp.StatusCode) { return nil, lastErr } } return nil, lastErr } type apiError struct { status int message string retryAfter string } func (e *apiError) Error() string { return e.message } func retryAfterFromErr(err error) (string, bool) { if ae, ok := err.(*apiError); ok && ae.retryAfter != "" { return ae.retryAfter, true } return "", false } func httpError(status int, body []byte, retryAfter string) error { msg := string(body) var formatted string switch status { case http.StatusForbidden: formatted = fmt.Sprintf("access denied (403): %s - This endpoint requires premium access", msg) case http.StatusUnauthorized: formatted = fmt.Sprintf("unauthorized (401): %s - Check your API key", msg) case http.StatusTooManyRequests: formatted = fmt.Sprintf("rate limit exceeded (429): %s", msg) case http.StatusBadRequest: formatted = fmt.Sprintf("bad request (400): %s", msg) default: formatted = fmt.Sprintf("API request failed with status %d: %s", status, msg) } return &apiError{status: status, message: formatted, retryAfter: retryAfter} } func (c *Client) Get(path string, params url.Values) ([]byte, error) { return c.doRequest(http.MethodGet, path, params, nil) } func (c *Client) Post(path string, body interface{}) ([]byte, error) { return c.doRequest(http.MethodPost, path, nil, body) } func (c *Client) Delete(path string) ([]byte, error) { return c.doRequest(http.MethodDelete, path, nil, nil) } func (c *Client) Patch(path string, body interface{}) ([]byte, error) { return c.doRequest(http.MethodPatch, path, nil, body) } func (c *Client) Put(path string, body interface{}) ([]byte, error) { return c.doRequest(http.MethodPut, path, nil, body) } func (c *Client) ListServices() ([]byte, error) { return c.Get("/", nil) }