Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ if err != nil {
| `api_error` | Server error | 7 |
| `ambiguous` | Multiple matches found | 8 |
| `validation` | Validation error (400, 422) | 9 |
| `api_disabled` | Public API access disabled for the account | 10 |

## Caching

Expand Down
6 changes: 6 additions & 0 deletions go/pkg/basecamp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,12 @@ func (c *Client) singleRequest(ctx context.Context, method, url string, body any
return nil, ErrForbidden("Access denied")

case http.StatusNotFound: // 404
if reasonErr := checkReasonHeader(resp); reasonErr != nil {
Comment thread
robzolkos marked this conversation as resolved.
if reasonErr.RequestID == "" {
reasonErr.RequestID = resp.Header.Get("X-Request-Id")
}
return nil, reasonErr
}
return nil, ErrNotFound("Resource", url)

case http.StatusInternalServerError: // 500
Expand Down
28 changes: 28 additions & 0 deletions go/pkg/basecamp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,31 @@ func TestSingleRequest_201EmptyBodyNotNormalized(t *testing.T) {
t.Error("expected UnmarshalData error for empty 201 body, got nil")
}
}

func TestSingleRequest_404APIDisabledPreservesRequestID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Reason", "API Disabled")
w.Header().Set("X-Request-Id", "req-123")
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

cfg := &Config{BaseURL: server.URL, CacheEnabled: false}
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"})

_, err := client.Get(context.Background(), "/test.json")
if err == nil {
t.Fatal("expected error, got nil")
}

e, ok := err.(*Error)
if !ok {
t.Fatalf("expected *Error, got %T", err)
}
if e.Code != CodeAPIDisabled {
t.Fatalf("Code = %q, want %q", e.Code, CodeAPIDisabled)
}
if e.RequestID != "req-123" {
t.Fatalf("RequestID = %q, want %q", e.RequestID, "req-123")
}
}
66 changes: 47 additions & 19 deletions go/pkg/basecamp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,31 @@ var (

// Error codes for API responses.
const (
CodeUsage = "usage"
CodeNotFound = "not_found"
CodeAuth = "auth_required"
CodeForbidden = "forbidden"
CodeRateLimit = "rate_limit"
CodeNetwork = "network"
CodeAPI = "api_error"
CodeValidation = "validation"
CodeAmbiguous = "ambiguous"
CodeUsage = "usage"
CodeNotFound = "not_found"
CodeAuth = "auth_required"
CodeForbidden = "forbidden"
CodeRateLimit = "rate_limit"
CodeNetwork = "network"
CodeAPI = "api_error"
CodeValidation = "validation"
CodeAmbiguous = "ambiguous"
CodeAPIDisabled = "api_disabled"
)

// Exit codes for CLI tools.
const (
ExitOK = 0 // Success
ExitUsage = 1 // Invalid arguments or flags
ExitNotFound = 2 // Resource not found
ExitAuth = 3 // Not authenticated
ExitForbidden = 4 // Access denied (scope issue)
ExitRateLimit = 5 // Rate limited (429)
ExitNetwork = 6 // Connection/DNS/timeout error
ExitAPI = 7 // Server returned error
ExitAmbiguous = 8 // Multiple matches for name
ExitValidation = 9 // Validation error (422)
ExitOK = 0 // Success
ExitUsage = 1 // Invalid arguments or flags
ExitNotFound = 2 // Resource not found
ExitAuth = 3 // Not authenticated
ExitForbidden = 4 // Access denied (scope issue)
ExitRateLimit = 5 // Rate limited (429)
ExitNetwork = 6 // Connection/DNS/timeout error
ExitAPI = 7 // Server returned error
ExitAmbiguous = 8 // Multiple matches for name
ExitValidation = 9 // Validation error (422)
ExitAPIDisabled = 10 // API access disabled for account
)

// Error is a structured error with code, message, and optional hint.
Expand Down Expand Up @@ -93,6 +95,8 @@ func ExitCodeFor(code string) int {
return ExitValidation
case CodeAmbiguous:
return ExitAmbiguous
case CodeAPIDisabled:
return ExitAPIDisabled
default:
return ExitAPI
}
Expand Down Expand Up @@ -125,6 +129,30 @@ func ErrNotFoundHint(resource, identifier, hint string) *Error {
}
}

// ErrAPIDisabled creates an error for when API access has been disabled by an
// account administrator. The Basecamp API returns 404 with a "Reason: API
// Disabled" header in this case.
func ErrAPIDisabled() *Error {
return &Error{
Code: CodeAPIDisabled,
Message: "API access is disabled for this account",
Hint: "An administrator can re-enable it in Adminland under Manage API access",
HTTPStatus: 404,
}
}

// ErrAccountInactive creates an error for when the account is inactive (expired
// trial or suspended). The Basecamp API returns 404 with a "Reason: Account
// Inactive" header in this case.
func ErrAccountInactive() *Error {
return &Error{
Code: CodeNotFound,
Message: "Account is inactive",
Hint: "The account may have an expired trial or be suspended",
HTTPStatus: 404,
}
}

// ErrAuth creates an authentication error.
func ErrAuth(msg string) *Error {
return &Error{
Expand Down
43 changes: 43 additions & 0 deletions go/pkg/basecamp/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func TestExitCodeFor(t *testing.T) {
{CodeAPI, ExitAPI},
{CodeValidation, ExitValidation},
{CodeAmbiguous, ExitAmbiguous},
{CodeAPIDisabled, ExitAPIDisabled},
{"unknown_code", ExitAPI},
}

Expand Down Expand Up @@ -208,6 +209,48 @@ func TestErrAPI(t *testing.T) {
}
}

func TestErrAPIDisabled(t *testing.T) {
e := ErrAPIDisabled()
if e.Code != CodeAPIDisabled {
t.Errorf("Code = %q, want %q", e.Code, CodeAPIDisabled)
}
if e.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", e.HTTPStatus)
}
if e.Hint == "" {
t.Error("expected non-empty hint")
}
if e.Message == "" {
t.Error("expected non-empty message")
}
}

func TestErrAPIDisabled_ExitCode(t *testing.T) {
e := ErrAPIDisabled()
if got := e.ExitCode(); got != ExitAPIDisabled {
t.Errorf("ExitCode() = %d, want %d", got, ExitAPIDisabled)
}
}

func TestErrAccountInactive(t *testing.T) {
e := ErrAccountInactive()
if e.Code != CodeNotFound {
t.Errorf("Code = %q, want %q", e.Code, CodeNotFound)
}
if e.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", e.HTTPStatus)
}
if e.Hint == "" {
t.Error("expected non-empty hint")
}
}

func TestExitCodeFor_APIDisabled(t *testing.T) {
if got := ExitCodeFor(CodeAPIDisabled); got != ExitAPIDisabled {
t.Errorf("ExitCodeFor(%q) = %d, want %d", CodeAPIDisabled, got, ExitAPIDisabled)
}
}

func TestSentinelErrors(t *testing.T) {
if ErrCircuitOpen == nil {
t.Error("ErrCircuitOpen should not be nil")
Expand Down
22 changes: 22 additions & 0 deletions go/pkg/basecamp/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ func checkResponse(resp *http.Response, body []byte) error {
case http.StatusForbidden:
return &Error{Code: CodeForbidden, Message: msgOrDefault(serverMsg, "access denied"), Hint: serverHint, HTTPStatus: 403, RequestID: requestID}
case http.StatusNotFound:
if reasonErr := checkReasonHeader(resp); reasonErr != nil {
reasonErr.RequestID = requestID
return reasonErr
}
return &Error{Code: CodeNotFound, Message: msgOrDefault(serverMsg, "resource not found"), Hint: serverHint, HTTPStatus: 404, RequestID: requestID}
case http.StatusUnprocessableEntity:
return &Error{Code: CodeValidation, Message: msgOrDefault(serverMsg, "validation error"), Hint: serverHint, HTTPStatus: 422, RequestID: requestID}
Expand All @@ -98,6 +102,24 @@ func checkResponse(resp *http.Response, body []byte) error {
}
}

// checkReasonHeader inspects the Reason response header on 404s to detect
// specific account-level conditions like disabled API access or inactive
// accounts. Returns nil when no special Reason header is present.
func checkReasonHeader(resp *http.Response) *Error {
if resp == nil {
return nil
}
reason := resp.Header.Get("Reason")
switch reason {
case "API Disabled":
return ErrAPIDisabled()
case "Account Inactive":
return ErrAccountInactive()
default:
return nil
}
}

// maxErrorMessageLen caps server error messages to prevent unbounded memory growth.
const maxErrorMessageLen = 500

Expand Down
141 changes: 141 additions & 0 deletions go/pkg/basecamp/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,147 @@ func TestMarshalBody_ReturnsReplayableReader(t *testing.T) {
}
}

func TestCheckReasonHeader_APIDisabled(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{"Reason": []string{"API Disabled"}},
}
err := checkReasonHeader(resp)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Code != CodeAPIDisabled {
t.Errorf("Code = %q, want %q", err.Code, CodeAPIDisabled)
}
if err.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", err.HTTPStatus)
}
}

func TestCheckReasonHeader_AccountInactive(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{"Reason": []string{"Account Inactive"}},
}
err := checkReasonHeader(resp)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Code != CodeNotFound {
t.Errorf("Code = %q, want %q", err.Code, CodeNotFound)
}
if err.Message != "Account is inactive" {
t.Errorf("Message = %q, want %q", err.Message, "Account is inactive")
}
}

func TestCheckReasonHeader_NoReason(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{},
}
if err := checkReasonHeader(resp); err != nil {
t.Errorf("expected nil, got %v", err)
}
}

func TestCheckReasonHeader_NilResponse(t *testing.T) {
if err := checkReasonHeader(nil); err != nil {
t.Errorf("expected nil, got %v", err)
}
}

func TestCheckReasonHeader_UnknownReason(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{"Reason": []string{"Something Else"}},
}
if err := checkReasonHeader(resp); err != nil {
t.Errorf("expected nil for unknown reason, got %v", err)
}
}

func TestCheckResponse_404_APIDisabled(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{"Reason": []string{"API Disabled"}},
}
err := checkResponse(resp, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
e, ok := err.(*Error)
if !ok {
t.Fatalf("expected *Error, got %T", err)
}
if e.Code != CodeAPIDisabled {
t.Errorf("Code = %q, want %q", e.Code, CodeAPIDisabled)
}
if e.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", e.HTTPStatus)
}
}

func TestCheckResponse_404_AccountInactive(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{"Reason": []string{"Account Inactive"}},
}
err := checkResponse(resp, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
e, ok := err.(*Error)
if !ok {
t.Fatalf("expected *Error, got %T", err)
}
if e.Code != CodeNotFound {
t.Errorf("Code = %q, want %q", e.Code, CodeNotFound)
}
if e.Message != "Account is inactive" {
t.Errorf("Message = %q, want %q", e.Message, "Account is inactive")
}
}

func TestCheckResponse_404_NoReason(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{},
}
err := checkResponse(resp, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
e, ok := err.(*Error)
if !ok {
t.Fatalf("expected *Error, got %T", err)
}
if e.Code != CodeNotFound {
t.Errorf("Code = %q, want %q", e.Code, CodeNotFound)
}
if e.Message != "resource not found" {
t.Errorf("Message = %q, want generic not-found message", e.Message)
}
}

func TestCheckResponse_404_APIDisabled_PreservesRequestID(t *testing.T) {
resp := &http.Response{
StatusCode: 404,
Header: http.Header{
"Reason": []string{"API Disabled"},
"X-Request-Id": []string{"abc-123"},
},
}
err := checkResponse(resp, nil)
e, ok := err.(*Error)
if !ok {
t.Fatalf("expected *Error, got %T", err)
}
if e.RequestID != "abc-123" {
t.Errorf("RequestID = %q, want %q", e.RequestID, "abc-123")
}
}

func TestDerefInt64(t *testing.T) {
var v int64 = 42
if got := derefInt64(&v); got != 42 {
Expand Down
Loading
Loading