Implement comprehensive error types and handling

Add complete error handling system for Kanboard API client:

Sentinel errors:
- Network: ErrConnectionFailed, ErrTimeout
- Auth: ErrUnauthorized, ErrForbidden
- Resources: ErrNotFound, ErrProjectNotFound, ErrTaskNotFound,
  ErrColumnNotFound, ErrCommentNotFound
- Logic: ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn,
  ErrTaskClosed, ErrTaskOpen
- Validation: ErrEmptyTitle, ErrInvalidProjectID

Helper functions:
- IsNotFound() - checks all not-found error variants
- IsUnauthorized() - checks auth errors
- IsAPIError() - checks for API errors via errors.As

All errors support errors.Is/errors.As for proper error
wrapping and context preservation.

Closes: kanboard-api-s7k
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:13:09 +01:00
commit 79385df87b
3 changed files with 289 additions and 1 deletions

View file

@ -17,7 +17,7 @@
{"id":"kanboard-api-l72","title":"Implement global task search with parallel execution","description":"Implement cross-project task search with parallel execution.\n\n## Method to implement\n- SearchTasksGlobally(ctx, query string) ([]Task, error)\n\n## Workflow\n1. Get all projects via getAllProjects\n2. Execute searchTasks for each project in parallel (errgroup)\n3. Aggregate and return all results\n4. Respect context cancellation\n\n## Implementation details\n- Use golang.org/x/sync/errgroup for parallel execution\n- Results channel to collect tasks\n- Handle errors from any project search\n- Context propagation for cancellation\n\n## Files to modify\n- tasks.go\n\n## Acceptance criteria\n- Parallel execution improves performance\n- Context cancellation stops all goroutines\n- Single project failure cancels remaining searches","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.533649255+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.533649255+01:00","dependencies":[{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:45:32.11473352+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-fue","type":"blocks","created_at":"2026-01-15T17:45:32.178153305+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-l9b","title":"Implement Column API methods","description":"Implement direct API methods for column operations.\n\n## Methods to implement\n- GetColumns(ctx, projectID int) ([]Column, error) - getColumns\n- GetColumn(ctx, columnID int) (*Column, error) - getColumn\n\n## Files to create\n- columns.go\n\n## Acceptance criteria\n- Columns returned sorted by position\n- Returns ErrColumnNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.23369865+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.23369865+01:00","dependencies":[{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.022754817+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.094484504+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-ob2","title":"Add MIT license","description":"Add MIT license file to the repository.\n\n## Files to create\n- LICENSE\n\n## Content\nStandard MIT license text with appropriate copyright holder.\n\n## Acceptance criteria\n- Valid MIT license\n- Proper copyright attribution","status":"open","priority":3,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.955909551+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.955909551+01:00"}
{"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:54.484116412+01:00"}
{"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:13:03.195687148+01:00","closed_at":"2026-01-15T18:13:03.195687148+01:00","close_reason":"Closed"}
{"id":"kanboard-api-t36","title":"Implement MoveToNextColumn with column logic","description":"Implement TaskScope.MoveToNextColumn with automatic column position logic.\n\n## Method to implement\n- TaskScope.MoveToNextColumn(ctx) error\n\n## Workflow\n1. Get task via getTask\n2. Get columns for task's project via getColumns\n3. Sort columns by position\n4. Find current column position\n5. If last column: return ErrAlreadyInLastColumn\n6. Move to next column via moveTaskPosition\n\n## Files to modify\n- task_scope.go\n\n## Acceptance criteria\n- Correctly identifies next column by position\n- Returns ErrAlreadyInLastColumn for last column\n- Handles column gaps (positions 1, 3, 5 etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.900647957+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.900647957+01:00","dependencies":[{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:45:32.256714732+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:45:32.319227723+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-ukc","title":"Set up Mage build system","description":"Set up Mage build system for development automation.\n\n## Directory structure\n```\nmagefiles/\n├── go.mod\n├── go.sum\n└── magefile.go\n```\n\n## Targets to implement\n- Test() - Run all tests with race detection and coverage\n- Coverage() - Run tests and open HTML coverage report\n- Lint() - Run golangci-lint\n- Build() - Verify module compiles\n\n## Files to create\n- magefiles/go.mod\n- magefiles/magefile.go\n\n## Acceptance criteria\n- `mage test` runs tests with -race and -coverprofile\n- `mage lint` runs golangci-lint\n- `mage build` verifies compilation","status":"open","priority":2,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:30.260235504+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:30.260235504+01:00"}
{"id":"kanboard-api-uls","title":"Implement Client struct with fluent configuration","description":"Implement the main Client struct with fluent builder pattern for configuration.\n\n## Requirements\n- Client struct with baseURL, httpClient, auth, logger, requestID\n- NewClient(baseURL string) constructor\n- Fluent configuration methods:\n - WithAPIToken(token string) *Client\n - WithBasicAuth(username, password string) *Client\n - WithHTTPClient(client *http.Client) *Client\n - WithTimeout(timeout time.Duration) *Client\n - WithLogger(logger *slog.Logger) *Client\n- Default timeout of 30 seconds\n- Thread-safe for concurrent use\n\n## Files to create\n- client.go\n\n## Acceptance criteria\n- Client is immutable after configuration\n- Thread-safe concurrent usage\n- Configurable HTTP client and logger","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.051926732+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:54.051926732+01:00","dependencies":[{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-2g1","type":"blocks","created_at":"2026-01-15T17:42:48.457362117+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-k33","type":"blocks","created_at":"2026-01-15T17:42:48.576622508+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-s7k","type":"blocks","created_at":"2026-01-15T17:42:48.749342195+01:00","created_by":"Oliver Jakoubek"}]}

View file

@ -5,10 +5,17 @@ import (
"fmt"
)
// Network errors
var (
// ErrConnectionFailed indicates a connection to the Kanboard server failed.
ErrConnectionFailed = errors.New("connection to Kanboard server failed")
// ErrTimeout indicates a request timed out.
ErrTimeout = errors.New("request timed out")
)
// Authentication errors
var (
// ErrUnauthorized indicates authentication failed.
ErrUnauthorized = errors.New("authentication failed: invalid credentials")
@ -16,6 +23,48 @@ var (
ErrForbidden = errors.New("access forbidden: insufficient permissions")
)
// Resource errors
var (
// ErrNotFound indicates a resource was not found.
ErrNotFound = errors.New("resource not found")
// ErrProjectNotFound indicates the specified project was not found.
ErrProjectNotFound = errors.New("project not found")
// ErrTaskNotFound indicates the specified task was not found.
ErrTaskNotFound = errors.New("task not found")
// ErrColumnNotFound indicates the specified column was not found.
ErrColumnNotFound = errors.New("column not found")
// ErrCommentNotFound indicates the specified comment was not found.
ErrCommentNotFound = errors.New("comment not found")
)
// Logic errors
var (
// ErrAlreadyInLastColumn indicates a task is already in the last column.
ErrAlreadyInLastColumn = errors.New("task is already in the last column")
// ErrAlreadyInFirstColumn indicates a task is already in the first column.
ErrAlreadyInFirstColumn = errors.New("task is already in the first column")
// ErrTaskClosed indicates a task is already closed.
ErrTaskClosed = errors.New("task is already closed")
// ErrTaskOpen indicates a task is already open.
ErrTaskOpen = errors.New("task is already open")
)
// Validation errors
var (
// ErrEmptyTitle indicates a task title cannot be empty.
ErrEmptyTitle = errors.New("task title cannot be empty")
// ErrInvalidProjectID indicates an invalid project ID was provided.
ErrInvalidProjectID = errors.New("invalid project ID")
)
// APIError represents an error returned by the Kanboard API.
type APIError struct {
Code int
@ -26,3 +75,23 @@ type APIError struct {
func (e *APIError) Error() string {
return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message)
}
// IsNotFound returns true if the error indicates a resource was not found.
func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound) ||
errors.Is(err, ErrProjectNotFound) ||
errors.Is(err, ErrTaskNotFound) ||
errors.Is(err, ErrColumnNotFound) ||
errors.Is(err, ErrCommentNotFound)
}
// IsUnauthorized returns true if the error indicates an authentication failure.
func IsUnauthorized(err error) bool {
return errors.Is(err, ErrUnauthorized)
}
// IsAPIError returns true if the error is an APIError from the Kanboard API.
func IsAPIError(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr)
}

219
errors_test.go Normal file
View file

@ -0,0 +1,219 @@
package kanboard
import (
"errors"
"fmt"
"testing"
)
func TestAPIError_Error(t *testing.T) {
tests := []struct {
name string
err *APIError
expected string
}{
{
name: "invalid request",
err: &APIError{Code: -32600, Message: "Invalid Request"},
expected: "Kanboard API error (code -32600): Invalid Request",
},
{
name: "method not found",
err: &APIError{Code: -32601, Message: "Method not found"},
expected: "Kanboard API error (code -32601): Method not found",
},
{
name: "custom error",
err: &APIError{Code: 1001, Message: "Task not found"},
expected: "Kanboard API error (code 1001): Task not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("APIError.Error() = %q, want %q", got, tt.expected)
}
})
}
}
func TestIsNotFound(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"ErrNotFound", ErrNotFound, true},
{"ErrProjectNotFound", ErrProjectNotFound, true},
{"ErrTaskNotFound", ErrTaskNotFound, true},
{"ErrColumnNotFound", ErrColumnNotFound, true},
{"ErrCommentNotFound", ErrCommentNotFound, true},
{"wrapped ErrNotFound", fmt.Errorf("context: %w", ErrNotFound), true},
{"wrapped ErrTaskNotFound", fmt.Errorf("getting task: %w", ErrTaskNotFound), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"ErrConnectionFailed", ErrConnectionFailed, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNotFound(tt.err); got != tt.expected {
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestIsUnauthorized(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"ErrUnauthorized", ErrUnauthorized, true},
{"wrapped ErrUnauthorized", fmt.Errorf("auth failed: %w", ErrUnauthorized), true},
{"ErrForbidden", ErrForbidden, false},
{"ErrNotFound", ErrNotFound, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsUnauthorized(tt.err); got != tt.expected {
t.Errorf("IsUnauthorized(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestIsAPIError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"APIError", &APIError{Code: -32600, Message: "Invalid"}, true},
{"wrapped APIError", fmt.Errorf("call failed: %w", &APIError{Code: -32600, Message: "Invalid"}), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"ErrNotFound", ErrNotFound, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAPIError(tt.err); got != tt.expected {
t.Errorf("IsAPIError(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestErrorsIs(t *testing.T) {
// Test that errors.Is works correctly with sentinel errors
tests := []struct {
name string
err error
target error
expected bool
}{
{"direct match", ErrTaskNotFound, ErrTaskNotFound, true},
{"wrapped match", fmt.Errorf("ctx: %w", ErrTaskNotFound), ErrTaskNotFound, true},
{"double wrapped", fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrTaskNotFound)), ErrTaskNotFound, true},
{"different error", ErrTaskNotFound, ErrProjectNotFound, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errors.Is(tt.err, tt.target); got != tt.expected {
t.Errorf("errors.Is(%v, %v) = %v, want %v", tt.err, tt.target, got, tt.expected)
}
})
}
}
func TestErrorsAs(t *testing.T) {
// Test that errors.As works correctly with APIError
apiErr := &APIError{Code: -32600, Message: "Invalid Request"}
wrappedErr := fmt.Errorf("call failed: %w", apiErr)
var target *APIError
// Direct APIError
if !errors.As(apiErr, &target) {
t.Error("errors.As should match direct APIError")
}
if target.Code != -32600 {
t.Errorf("expected Code=-32600, got %d", target.Code)
}
// Wrapped APIError
target = nil
if !errors.As(wrappedErr, &target) {
t.Error("errors.As should match wrapped APIError")
}
if target.Message != "Invalid Request" {
t.Errorf("expected Message='Invalid Request', got %s", target.Message)
}
// Non-APIError
target = nil
if errors.As(ErrNotFound, &target) {
t.Error("errors.As should not match non-APIError")
}
}
func TestSentinelErrorMessages(t *testing.T) {
// Ensure all sentinel errors have meaningful messages
sentinels := []struct {
name string
err error
}{
{"ErrConnectionFailed", ErrConnectionFailed},
{"ErrTimeout", ErrTimeout},
{"ErrUnauthorized", ErrUnauthorized},
{"ErrForbidden", ErrForbidden},
{"ErrNotFound", ErrNotFound},
{"ErrProjectNotFound", ErrProjectNotFound},
{"ErrTaskNotFound", ErrTaskNotFound},
{"ErrColumnNotFound", ErrColumnNotFound},
{"ErrCommentNotFound", ErrCommentNotFound},
{"ErrAlreadyInLastColumn", ErrAlreadyInLastColumn},
{"ErrAlreadyInFirstColumn", ErrAlreadyInFirstColumn},
{"ErrTaskClosed", ErrTaskClosed},
{"ErrTaskOpen", ErrTaskOpen},
{"ErrEmptyTitle", ErrEmptyTitle},
{"ErrInvalidProjectID", ErrInvalidProjectID},
}
for _, s := range sentinels {
t.Run(s.name, func(t *testing.T) {
if s.err == nil {
t.Errorf("%s should not be nil", s.name)
}
if s.err.Error() == "" {
t.Errorf("%s should have a non-empty error message", s.name)
}
})
}
}
func TestErrorWrapping(t *testing.T) {
// Test error wrapping preserves context
originalErr := ErrTaskNotFound
wrappedOnce := fmt.Errorf("getting task %d: %w", 42, originalErr)
wrappedTwice := fmt.Errorf("in board scope: %w", wrappedOnce)
// Should preserve original error
if !errors.Is(wrappedTwice, ErrTaskNotFound) {
t.Error("wrapped error should match original with errors.Is")
}
// Should include context in message
if wrappedTwice.Error() != "in board scope: getting task 42: task not found" {
t.Errorf("unexpected error message: %s", wrappedTwice.Error())
}
}