diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index acc6643..e3708ed 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -10,7 +10,7 @@ {"id":"checkvist-api-8bn","title":"Write unit tests for Client and Auth","description":"Create client_test.go with tests using httptest.Server:\n- TestNewClient_Defaults\n- TestNewClient_WithOptions\n- TestAuthenticate_Success\n- TestAuthenticate_InvalidCredentials\n- TestAuthenticate_2FA\n- TestTokenRefresh_Auto\n- TestTokenRefresh_Manual\n- TestCurrentUser\n- TestRetryLogic_429\n- TestRetryLogic_5xx\n- TestRetryLogic_NetworkError\nUse table-driven tests. Create testdata/auth/ fixtures.","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.964610587+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:36.964610587+01:00","dependencies":[{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:33:12.783142853+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:33:13.232028837+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-8jh","title":"Implement repeating tasks configuration","description":"Add P2 (nice-to-have) repeat support to TaskBuilder:\n- WithRepeat(pattern string) *TaskBuilder\nSupport Checkvist smart syntax for repeats.\nDocument common patterns in GoDoc.","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:56.826106108+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:56.826106108+01:00","dependencies":[{"issue_id":"checkvist-api-8jh","depends_on_id":"checkvist-api-tjk","type":"blocks","created_at":"2026-01-14T12:33:03.159849575+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-8q3","title":"Set up Mage build targets","description":"Create magefiles/magefile.go with:\n- Test() - run go test -v ./...\n- Coverage() - run go test -coverprofile=coverage.out ./...\n- Lint() - run staticcheck ./...\n- Fmt() - run gofmt -w .\n- Check() - run all quality checks (fmt, vet, staticcheck, test)\nEnsure magefiles has its own go.mod importing magefile.org/mage","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:09.228450637+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:09.228450637+01:00","dependencies":[{"issue_id":"checkvist-api-8q3","depends_on_id":"checkvist-api-5wr","type":"blocks","created_at":"2026-01-14T12:32:48.556022687+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"checkvist-api-8u6","title":"Implement HTTP request helper with retry logic","description":"Add internal HTTP helper to client.go:\n- doRequest(ctx, method, path, body) helper for all API calls\n- Automatic authentication check before requests\n- JSON marshaling/unmarshaling\n- Exponential backoff retry for:\n - HTTP 429 (Too Many Requests)\n - HTTP 5xx (Server Errors)\n - Network errors (timeout, connection reset)\n- Respect context cancellation\n- Optional debug logging of requests/responses via slog","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.780244392+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:08.780244392+01:00","dependencies":[{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.973194416+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-mnh","type":"blocks","created_at":"2026-01-14T12:32:48.268500727+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"checkvist-api-8u6","title":"Implement HTTP request helper with retry logic","description":"Add internal HTTP helper to client.go:\n- doRequest(ctx, method, path, body) helper for all API calls\n- Automatic authentication check before requests\n- JSON marshaling/unmarshaling\n- Exponential backoff retry for:\n - HTTP 429 (Too Many Requests)\n - HTTP 5xx (Server Errors)\n - Network errors (timeout, connection reset)\n- Respect context cancellation\n- Optional debug logging of requests/responses via slog","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.780244392+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:27:52.914675409+01:00","closed_at":"2026-01-14T13:27:52.914675409+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.973194416+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-mnh","type":"blocks","created_at":"2026-01-14T12:32:48.268500727+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-93m","title":"Create CHANGELOG","description":"Create CHANGELOG.md following Keep a Changelog format:\n- [Unreleased] section for ongoing work\n- Initial release preparation notes\n- Document all features implemented","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:39.009748936+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:39.009748936+01:00"} {"id":"checkvist-api-bbx","title":"Write unit tests for Notes","description":"Create notes_test.go with tests:\n- TestNotes_List\n- TestNotes_Create\n- TestNotes_Update\n- TestNotes_Delete\nUse table-driven tests. Create testdata/notes/ fixtures.","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.829382141+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:37.829382141+01:00","dependencies":[{"issue_id":"checkvist-api-bbx","depends_on_id":"checkvist-api-5ab","type":"blocks","created_at":"2026-01-14T12:33:14.119755191+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-br3","title":"Core API Operations","description":"Phase 2: Implement CRUD operations for Checklists, Tasks, and Notes. All P0 (must-have) features for the library.","status":"open","priority":0,"issue_type":"epic","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.20627925+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:53.20627925+01:00"} diff --git a/client.go b/client.go index 21c4ff9..a366b16 100644 --- a/client.go +++ b/client.go @@ -11,11 +11,14 @@ package checkvist import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" + "math/rand" "net/http" "net/url" "strings" @@ -255,3 +258,165 @@ func (c *Client) CurrentUser(ctx context.Context) (*User, error) { return &user, nil } + +// doRequest performs an HTTP request with automatic authentication and retry logic. +// It handles JSON marshaling of the request body and unmarshaling of the response. +func (c *Client) doRequest(ctx context.Context, method, path string, body any, result any) error { + if err := c.ensureAuthenticated(ctx); err != nil { + return err + } + + var bodyReader io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + var lastErr error + for attempt := 0; attempt <= c.retryConf.MaxRetries; attempt++ { + if attempt > 0 { + delay := c.calculateRetryDelay(attempt) + c.logger.Debug("retrying request", + "attempt", attempt, + "delay", delay, + "path", path, + ) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + + // Reset body reader for retry + if body != nil { + bodyBytes, _ := json.Marshal(body) + bodyReader = bytes.NewReader(bodyBytes) + } + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("X-Client-Token", c.getToken()) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + c.logger.Debug("sending request", + "method", method, + "path", path, + "attempt", attempt, + ) + + resp, err := c.httpClient.Do(req) + if err != nil { + if c.shouldRetry(err, nil) { + lastErr = err + continue + } + return fmt.Errorf("request failed: %w", err) + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("reading response body: %w", err) + continue + } + + c.logger.Debug("received response", + "status", resp.StatusCode, + "path", path, + ) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if result != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + } + return nil + } + + apiErr := NewAPIError(resp, string(respBody)) + if c.shouldRetry(nil, resp) { + lastErr = apiErr + continue + } + + return apiErr + } + + if lastErr != nil { + return fmt.Errorf("request failed after %d retries: %w", c.retryConf.MaxRetries, lastErr) + } + return errors.New("request failed: unknown error") +} + +// shouldRetry determines if a request should be retried based on the error or response. +func (c *Client) shouldRetry(err error, resp *http.Response) bool { + if err != nil { + // Retry on network errors (timeout, connection reset, etc.) + return true + } + + if resp != nil { + // Retry on rate limiting + if resp.StatusCode == http.StatusTooManyRequests { + return true + } + // Retry on server errors + if resp.StatusCode >= 500 { + return true + } + } + + return false +} + +// calculateRetryDelay calculates the delay before the next retry attempt +// using exponential backoff with optional jitter. +func (c *Client) calculateRetryDelay(attempt int) time.Duration { + // Exponential backoff: baseDelay * 2^attempt + delay := c.retryConf.BaseDelay * time.Duration(1< c.retryConf.MaxDelay { + delay = c.retryConf.MaxDelay + } + + // Add jitter (0-25% of delay) + if c.retryConf.Jitter { + jitter := time.Duration(rand.Int63n(int64(delay / 4))) + delay += jitter + } + + return delay +} + +// doGet performs a GET request and decodes the response into result. +func (c *Client) doGet(ctx context.Context, path string, result any) error { + return c.doRequest(ctx, http.MethodGet, path, nil, result) +} + +// doPost performs a POST request with a JSON body and decodes the response. +func (c *Client) doPost(ctx context.Context, path string, body any, result any) error { + return c.doRequest(ctx, http.MethodPost, path, body, result) +} + +// doPut performs a PUT request with a JSON body and decodes the response. +func (c *Client) doPut(ctx context.Context, path string, body any, result any) error { + return c.doRequest(ctx, http.MethodPut, path, body, result) +} + +// doDelete performs a DELETE request. +func (c *Client) doDelete(ctx context.Context, path string) error { + return c.doRequest(ctx, http.MethodDelete, path, nil, nil) +}