From 67a972da84fdc6e7de0029022a7c09fb18012ccb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 30 Mar 2026 07:28:55 -0400 Subject: [PATCH 1/4] Handle Reason header on 404 responses for API Disabled and Account Inactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Basecamp API returns a Reason header on 404 responses to distinguish account-level conditions from regular not-found errors: - "Reason: API Disabled" — an administrator has turned off API access - "Reason: Account Inactive" — expired trial or suspended account Previously both cases returned a generic "resource not found" error, which was confusing for developers and end users. Now both checkResponse (used by service methods via the generated client) and the raw client's 404 handler inspect the Reason header and return specific, actionable errors: - ErrAPIDisabled() with CodeAPIDisabled and a hint to contact an admin - ErrAccountInactive() with CodeNotFound and a hint about the account state Added CodeAPIDisabled error code and ExitAPIDisabled (exit code 10) so CLI tools can programmatically detect this condition. --- go/pkg/basecamp/client.go | 3 + go/pkg/basecamp/errors.go | 66 ++++++++++----- go/pkg/basecamp/errors_test.go | 43 ++++++++++ go/pkg/basecamp/helpers.go | 22 +++++ go/pkg/basecamp/helpers_test.go | 141 ++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 19 deletions(-) diff --git a/go/pkg/basecamp/client.go b/go/pkg/basecamp/client.go index 6f030133c..7c799be74 100644 --- a/go/pkg/basecamp/client.go +++ b/go/pkg/basecamp/client.go @@ -781,6 +781,9 @@ 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 { + return nil, reasonErr + } return nil, ErrNotFound("Resource", url) case http.StatusInternalServerError: // 500 diff --git a/go/pkg/basecamp/errors.go b/go/pkg/basecamp/errors.go index 3bc768e50..7032320ba 100644 --- a/go/pkg/basecamp/errors.go +++ b/go/pkg/basecamp/errors.go @@ -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. @@ -93,6 +95,8 @@ func ExitCodeFor(code string) int { return ExitValidation case CodeAmbiguous: return ExitAmbiguous + case CodeAPIDisabled: + return ExitAPIDisabled default: return ExitAPI } @@ -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{ diff --git a/go/pkg/basecamp/errors_test.go b/go/pkg/basecamp/errors_test.go index f04d41ef4..74e96febe 100644 --- a/go/pkg/basecamp/errors_test.go +++ b/go/pkg/basecamp/errors_test.go @@ -84,6 +84,7 @@ func TestExitCodeFor(t *testing.T) { {CodeAPI, ExitAPI}, {CodeValidation, ExitValidation}, {CodeAmbiguous, ExitAmbiguous}, + {CodeAPIDisabled, ExitAPIDisabled}, {"unknown_code", ExitAPI}, } @@ -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") diff --git a/go/pkg/basecamp/helpers.go b/go/pkg/basecamp/helpers.go index b01e62622..f5020f718 100644 --- a/go/pkg/basecamp/helpers.go +++ b/go/pkg/basecamp/helpers.go @@ -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} @@ -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 diff --git a/go/pkg/basecamp/helpers_test.go b/go/pkg/basecamp/helpers_test.go index 4f3c5b2cc..317c5e122 100644 --- a/go/pkg/basecamp/helpers_test.go +++ b/go/pkg/basecamp/helpers_test.go @@ -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 { From f97cf29eaef4f422f7f290dbd617dccb23f5accc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 30 Mar 2026 07:55:58 -0400 Subject: [PATCH 2/4] Handle Reason header on 404 responses across all SDKs --- .../com/basecamp/sdk/BasecampException.kt | 21 +++++- .../kotlin/com/basecamp/sdk/Download.kt | 3 +- .../com/basecamp/sdk/services/BaseService.kt | 3 +- .../kotlin/com/basecamp/sdk/ErrorTest.kt | 46 ++++++++++++ python/src/basecamp/__init__.py | 2 + python/src/basecamp/errors.py | 24 ++++++- python/tests/test_errors.py | 27 +++++++ python/tests/test_http.py | 23 ++++++ ruby/lib/basecamp.rb | 11 ++- ruby/lib/basecamp/api_disabled_error.rb | 16 +++++ ruby/lib/basecamp/client.rb | 3 +- ruby/lib/basecamp/error.rb | 1 + ruby/lib/basecamp/error_code.rb | 1 + ruby/lib/basecamp/exit_code.rb | 1 + ruby/lib/basecamp/http.rb | 11 ++- ruby/test/basecamp/errors_test.rb | 38 ++++++++++ ruby/test/basecamp/http_test.rb | 22 ++++++ swift/Sources/Basecamp/BasecampError.swift | 24 +++++++ swift/Tests/BasecampTests/ErrorTests.swift | 41 +++++++++++ typescript/src/errors.ts | 34 ++++++++- typescript/tests/errors.test.ts | 72 +++++++++++++++++++ 21 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 ruby/lib/basecamp/api_disabled_error.rb diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index 6c9c65141..dbf9c7813 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -108,6 +108,13 @@ sealed class BasecampException( "Did you mean: ${matches.joinToString(", ")}" else "Be more specific", ) : BasecampException("Ambiguous $resource", CODE_AMBIGUOUS, hint) + /** API access disabled by account administrator (404 with Reason: API Disabled header). */ + class ApiDisabled( + message: String = "API access is disabled for this account", + hint: String? = "An administrator can re-enable it in Adminland under Manage API access", + requestId: String? = null, + ) : BasecampException(message, CODE_API_DISABLED, hint, 404, false, requestId) + /** Usage error (bad arguments, configuration errors). */ class Usage( message: String, @@ -123,6 +130,7 @@ sealed class BasecampException( const val CODE_API = "api_error" const val CODE_VALIDATION = "validation" const val CODE_AMBIGUOUS = "ambiguous" + const val CODE_API_DISABLED = "api_disabled" const val CODE_USAGE = "usage" private const val EXIT_OK = 0 @@ -135,6 +143,7 @@ sealed class BasecampException( private const val EXIT_API = 7 private const val EXIT_AMBIGUOUS = 8 private const val EXIT_VALIDATION = 9 + private const val EXIT_API_DISABLED = 10 /** Maps an error code to a CLI exit code. */ fun exitCodeFor(code: String): Int = when (code) { @@ -147,6 +156,7 @@ sealed class BasecampException( CODE_API -> EXIT_API CODE_AMBIGUOUS -> EXIT_AMBIGUOUS CODE_VALIDATION -> EXIT_VALIDATION + CODE_API_DISABLED -> EXIT_API_DISABLED else -> EXIT_API } @@ -165,12 +175,21 @@ sealed class BasecampException( hint: String? = null, requestId: String? = null, retryAfterSeconds: Int? = null, + reason: String? = null, ): BasecampException { val msg = truncateMessage(message ?: "Request failed (HTTP $httpStatus)") return when (httpStatus) { 401 -> Auth(msg, hint, requestId) 403 -> Forbidden(msg, hint, requestId) - 404 -> NotFound(msg, hint, requestId) + 404 -> when (reason) { + "API Disabled" -> ApiDisabled(requestId = requestId) + "Account Inactive" -> NotFound( + "Account is inactive", + "The account may have an expired trial or be suspended", + requestId, + ) + else -> NotFound(msg, hint, requestId) + } 429 -> RateLimit(retryAfterSeconds, msg, hint, requestId) 400, 422 -> Validation(msg, hint, httpStatus, requestId) else -> Api(msg, httpStatus, hint, httpStatus in 500..599, requestId) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt index b02e44c8b..ebbef313d 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt @@ -216,6 +216,7 @@ suspend fun AccountClient.downloadURL(rawURL: String): DownloadResult { // matching BaseService.errorFromResponse val requestId = response.headers["X-Request-Id"] val retryAfter = parseRetryAfter(response.headers["Retry-After"]) + val reason = response.headers["Reason"] var message: String = response.status.description.ifEmpty { "Request failed" } var hint: String? = null @@ -239,7 +240,7 @@ suspend fun AccountClient.downloadURL(rawURL: String): DownloadResult { // Body is not JSON or empty — use status text } - throw BasecampException.fromHttpStatus(status, message, hint, requestId, retryAfter) + throw BasecampException.fromHttpStatus(status, message, hint, requestId, retryAfter, reason) } } } diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/BaseService.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/BaseService.kt index ebfa630e6..e92127998 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/BaseService.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/BaseService.kt @@ -445,6 +445,7 @@ abstract class BaseService( val status = response.status.value val requestId = response.headers["X-Request-Id"] val retryAfter = parseRetryAfter(response.headers["Retry-After"]) + val reason = response.headers["Reason"] var message: String = response.status.description.ifEmpty { "Request failed" } var hint: String? = null @@ -466,7 +467,7 @@ abstract class BaseService( // Body is not JSON or empty — use status text } - return BasecampException.fromHttpStatus(status, message, hint, requestId, retryAfter) + return BasecampException.fromHttpStatus(status, message, hint, requestId, retryAfter, reason) } companion object { diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 1b09b8512..58df69a3b 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -107,6 +107,50 @@ class ErrorTest { assertIs(e) } + @Test + fun fromHttpStatusMaps404ApiDisabled() { + val e = BasecampException.fromHttpStatus(404, "Not found", reason = "API Disabled") + assertIs(e) + assertEquals("api_disabled", e.code) + assertEquals(404, e.httpStatus) + assertEquals(10, e.exitCode) + assertFalse(e.retryable) + assertTrue(e.hint?.contains("Adminland") == true) + } + + @Test + fun fromHttpStatusMaps404AccountInactive() { + val e = BasecampException.fromHttpStatus(404, "Not found", reason = "Account Inactive") + assertIs(e) + assertEquals("Account is inactive", e.message) + assertTrue(e.hint?.contains("expired trial") == true) + } + + @Test + fun fromHttpStatusMaps404NoReason() { + val e = BasecampException.fromHttpStatus(404, "Not found", reason = null) + assertIs(e) + assertEquals("Not found", e.message) + } + + @Test + fun fromHttpStatusMaps404ApiDisabledPreservesRequestId() { + val e = BasecampException.fromHttpStatus(404, "Not found", requestId = "req-123", reason = "API Disabled") + assertIs(e) + assertEquals("req-123", e.requestId) + } + + @Test + fun apiDisabledErrorProperties() { + val e = BasecampException.ApiDisabled() + assertEquals("api_disabled", e.code) + assertEquals(404, e.httpStatus) + assertEquals(10, e.exitCode) + assertFalse(e.retryable) + assertTrue(e.message!!.contains("disabled")) + assertTrue(e.hint?.contains("Adminland") == true) + } + @Test fun fromHttpStatusMaps429ToRateLimit() { val e = BasecampException.fromHttpStatus(429, "Too many requests", retryAfterSeconds = 10) @@ -152,6 +196,7 @@ class ErrorTest { BasecampException.Api("error", 500), BasecampException.Ambiguous("project"), BasecampException.Validation("invalid"), + BasecampException.ApiDisabled(), BasecampException.Usage("bad arg"), ) @@ -166,6 +211,7 @@ class ErrorTest { is BasecampException.Api -> "api" is BasecampException.Ambiguous -> "ambiguous" is BasecampException.Validation -> "validation" + is BasecampException.ApiDisabled -> "api_disabled" is BasecampException.Usage -> "usage" } assertTrue(code.isNotEmpty()) diff --git a/python/src/basecamp/__init__.py b/python/src/basecamp/__init__.py index 738b9737c..0f5dc235d 100644 --- a/python/src/basecamp/__init__.py +++ b/python/src/basecamp/__init__.py @@ -14,6 +14,7 @@ from basecamp.download import DownloadResult from basecamp.errors import ( AmbiguousError, + ApiDisabledError, ApiError, AuthError, BasecampError, @@ -42,6 +43,7 @@ "ValidationError", "NetworkError", "ApiError", + "ApiDisabledError", "AmbiguousError", "UsageError", "ErrorCode", diff --git a/python/src/basecamp/errors.py b/python/src/basecamp/errors.py index 139da5e8a..cdebc186e 100644 --- a/python/src/basecamp/errors.py +++ b/python/src/basecamp/errors.py @@ -16,6 +16,7 @@ class ErrorCode(StrEnum): API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + API_DISABLED = "api_disabled" class ExitCode(IntEnum): @@ -28,6 +29,7 @@ class ExitCode(IntEnum): API = 7 AMBIGUOUS = 8 VALIDATION = 9 + API_DISABLED = 10 _EXIT_CODE_MAP = { @@ -40,6 +42,7 @@ class ExitCode(IntEnum): ErrorCode.API: ExitCode.API, ErrorCode.AMBIGUOUS: ExitCode.AMBIGUOUS, ErrorCode.VALIDATION: ExitCode.VALIDATION, + ErrorCode.API_DISABLED: ExitCode.API_DISABLED, } @@ -119,6 +122,15 @@ def __init__(self, message: str = "Validation failed", **kwargs: Any): super().__init__(message, code=ErrorCode.VALIDATION, **kwargs) +class ApiDisabledError(BasecampError): + """Raised when API access has been disabled by an account administrator.""" + + def __init__(self, message: str = "API access is disabled for this account", **kwargs: Any): + kwargs.setdefault("hint", "An administrator can re-enable it in Adminland under Manage API access") + kwargs.setdefault("http_status", 404) + super().__init__(message, code=ErrorCode.API_DISABLED, **kwargs) + + def parse_error_message(body: str | bytes | None) -> str | None: """Extract error message from response body.""" if not body: @@ -145,7 +157,17 @@ def error_from_response(status: int, body: str | bytes | None, headers: dict[str elif status == 403: err = ForbiddenError(message or "Access denied", http_status=403) elif status == 404: - err = NotFoundError(message=_truncate(message or "Not found"), http_status=404) + reason = headers.get("Reason") or headers.get("reason") + if reason == "API Disabled": + err = ApiDisabledError(http_status=404) + elif reason == "Account Inactive": + err = NotFoundError( + message="Account is inactive", + hint="The account may have an expired trial or be suspended", + http_status=404, + ) + else: + err = NotFoundError(message=_truncate(message or "Not found"), http_status=404) elif status == 429: err = RateLimitError(_truncate(message or "Rate limited"), retry_after=retry_after, http_status=429) elif status in (400, 422): diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py index 47a9b68d0..84e912de5 100644 --- a/python/tests/test_errors.py +++ b/python/tests/test_errors.py @@ -6,6 +6,7 @@ from basecamp.errors import ( AmbiguousError, + ApiDisabledError, ApiError, AuthError, BasecampError, @@ -36,6 +37,7 @@ class TestErrorHierarchy: (ApiError, ErrorCode.API, ExitCode.API), (AmbiguousError, ErrorCode.AMBIGUOUS, ExitCode.AMBIGUOUS), (ValidationError, ErrorCode.VALIDATION, ExitCode.VALIDATION), + (ApiDisabledError, ErrorCode.API_DISABLED, ExitCode.API_DISABLED), ], ) def test_code_and_exit_code(self, cls, code, exit_code): @@ -118,6 +120,31 @@ def test_json_error_message_extracted(self): err = error_from_response(422, b'{"error": "Name is required"}') assert "Name is required" in str(err) + def test_404_api_disabled(self): + err = error_from_response(404, None, {"Reason": "API Disabled"}) + assert isinstance(err, ApiDisabledError) + assert err.code == ErrorCode.API_DISABLED + assert err.http_status == 404 + assert err.exit_code == 10 + assert "Adminland" in err.hint + + def test_404_account_inactive(self): + err = error_from_response(404, None, {"Reason": "Account Inactive"}) + assert isinstance(err, NotFoundError) + assert err.code == ErrorCode.NOT_FOUND + assert "inactive" in str(err) + assert "expired trial" in err.hint + + def test_404_no_reason_header(self): + err = error_from_response(404, None, {}) + assert isinstance(err, NotFoundError) + assert err.code == ErrorCode.NOT_FOUND + + def test_404_api_disabled_preserves_request_id(self): + err = error_from_response(404, None, {"Reason": "API Disabled", "X-Request-Id": "abc-123"}) + assert isinstance(err, ApiDisabledError) + assert err.request_id == "abc-123" + class TestParseErrorMessage: def test_json_error_field(self): diff --git a/python/tests/test_http.py b/python/tests/test_http.py index 57ec46902..bdd8795f4 100644 --- a/python/tests/test_http.py +++ b/python/tests/test_http.py @@ -8,6 +8,7 @@ from basecamp.auth import BearerAuth, StaticTokenProvider from basecamp.config import Config from basecamp.errors import ( + ApiDisabledError, ApiError, AuthError, NetworkError, @@ -77,6 +78,28 @@ def test_404_maps_to_not_found(self): with pytest.raises(NotFoundError): client.get("/test") + @respx.mock + def test_404_with_api_disabled_reason_maps_to_api_disabled(self): + respx.get("https://3.basecampapi.com/test").mock( + return_value=httpx.Response(404, headers={"Reason": "API Disabled"}) + ) + client = make_client() + with pytest.raises(ApiDisabledError) as exc_info: + client.get("/test") + assert exc_info.value.http_status == 404 + assert "Adminland" in exc_info.value.hint + + @respx.mock + def test_404_with_account_inactive_reason_maps_to_not_found(self): + respx.get("https://3.basecampapi.com/test").mock( + return_value=httpx.Response(404, headers={"Reason": "Account Inactive"}) + ) + client = make_client() + with pytest.raises(NotFoundError) as exc_info: + client.get("/test") + assert "inactive" in str(exc_info.value) + assert "expired trial" in exc_info.value.hint + @respx.mock def test_429_maps_to_rate_limit(self): respx.get("https://3.basecampapi.com/test").mock(return_value=httpx.Response(429, headers={"Retry-After": "1"})) diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb index 94f5899da..c134ac47c 100644 --- a/ruby/lib/basecamp.rb +++ b/ruby/lib/basecamp.rb @@ -89,7 +89,7 @@ def self.client( # @param body [String, nil] response body (will attempt JSON parse) # @param retry_after [Integer, nil] Retry-After header value # @return [Error] - def self.error_from_response(status, body = nil, retry_after: nil) + def self.error_from_response(status, body = nil, retry_after: nil, headers: {}) message = parse_error_message(body) || "Request failed" case status @@ -100,7 +100,14 @@ def self.error_from_response(status, body = nil, retry_after: nil) when 403 ForbiddenError.new(message) when 404 - NotFoundError.new(message: message) + reason = headers["Reason"] || headers["reason"] + if reason == "API Disabled" + ApiDisabledError.new + elsif reason == "Account Inactive" + NotFoundError.new(message: "Account is inactive", hint: "The account may have an expired trial or be suspended") + else + NotFoundError.new(message: message) + end when 429 RateLimitError.new(retry_after: retry_after) when 500 diff --git a/ruby/lib/basecamp/api_disabled_error.rb b/ruby/lib/basecamp/api_disabled_error.rb new file mode 100644 index 000000000..082c63216 --- /dev/null +++ b/ruby/lib/basecamp/api_disabled_error.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Basecamp + # Raised when API access has been disabled by an account administrator. + # The Basecamp API returns 404 with a "Reason: API Disabled" header. + class ApiDisabledError < Error + def initialize(message: "API access is disabled for this account", hint: nil) + super( + code: ErrorCode::API_DISABLED, + message: message, + hint: hint || "An administrator can re-enable it in Adminland under Manage API access", + http_status: 404 + ) + end + end +end diff --git a/ruby/lib/basecamp/client.rb b/ruby/lib/basecamp/client.rb index 105683c08..26990fb75 100644 --- a/ruby/lib/basecamp/client.rb +++ b/ruby/lib/basecamp/client.rb @@ -301,7 +301,8 @@ def download_url(raw_url) else # This shouldn't happen because Faraday's raise_error middleware # handles 4xx/5xx, but handle it defensively - raise Basecamp.error_from_response(response.status, response.body) + headers = response.headers.respond_to?(:to_h) ? response.headers.to_h : {} + raise Basecamp.error_from_response(response.status, response.body, headers: headers) end rescue => e duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round diff --git a/ruby/lib/basecamp/error.rb b/ruby/lib/basecamp/error.rb index b02ec16d7..9f4a93f9b 100644 --- a/ruby/lib/basecamp/error.rb +++ b/ruby/lib/basecamp/error.rb @@ -79,6 +79,7 @@ def self.exit_code_for(code) when ErrorCode::API then ExitCode::API when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS when ErrorCode::VALIDATION then ExitCode::VALIDATION + when ErrorCode::API_DISABLED then ExitCode::API_DISABLED else ExitCode::API end end diff --git a/ruby/lib/basecamp/error_code.rb b/ruby/lib/basecamp/error_code.rb index 6c66ca6cd..60a89e975 100644 --- a/ruby/lib/basecamp/error_code.rb +++ b/ruby/lib/basecamp/error_code.rb @@ -12,5 +12,6 @@ module ErrorCode API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + API_DISABLED = "api_disabled" end end diff --git a/ruby/lib/basecamp/exit_code.rb b/ruby/lib/basecamp/exit_code.rb index f52beb15a..e64914938 100644 --- a/ruby/lib/basecamp/exit_code.rb +++ b/ruby/lib/basecamp/exit_code.rb @@ -13,5 +13,6 @@ module ExitCode API = 7 AMBIGUOUS = 8 VALIDATION = 9 + API_DISABLED = 10 end end diff --git a/ruby/lib/basecamp/http.rb b/ruby/lib/basecamp/http.rb index 9a5e71ff4..c354e8652 100644 --- a/ruby/lib/basecamp/http.rb +++ b/ruby/lib/basecamp/http.rb @@ -447,8 +447,15 @@ def handle_error(error) when 403 Basecamp::ForbiddenError.new("Access denied") when 404 - message = Security.truncate(Basecamp.parse_error_message(body) || "Not found") - Basecamp::NotFoundError.new(message: message) + reason = headers["Reason"] || headers["reason"] + if reason == "API Disabled" + Basecamp::ApiDisabledError.new + elsif reason == "Account Inactive" + Basecamp::NotFoundError.new(message: "Account is inactive", hint: "The account may have an expired trial or be suspended") + else + message = Security.truncate(Basecamp.parse_error_message(body) || "Not found") + Basecamp::NotFoundError.new(message: message) + end when 429 Basecamp::RateLimitError.new(retry_after: retry_after) when 400, 422 diff --git a/ruby/test/basecamp/errors_test.rb b/ruby/test/basecamp/errors_test.rb index 755c172c7..42a206799 100644 --- a/ruby/test/basecamp/errors_test.rb +++ b/ruby/test/basecamp/errors_test.rb @@ -157,4 +157,42 @@ def test_parse_error_message_returns_nil_for_invalid_json assert_nil message end + + def test_api_disabled_error + error = Basecamp::ApiDisabledError.new + + assert_equal Basecamp::ErrorCode::API_DISABLED, error.code + assert_equal 404, error.http_status + assert_includes error.hint, "Adminland" + assert_includes error.message, "disabled" + end + + def test_api_disabled_exit_code + error = Basecamp::ApiDisabledError.new + + assert_equal Basecamp::ExitCode::API_DISABLED, error.exit_code + assert_equal 10, error.exit_code + end + + def test_error_from_response_404_api_disabled + error = Basecamp.error_from_response(404, nil, headers: { "Reason" => "API Disabled" }) + + assert_instance_of Basecamp::ApiDisabledError, error + assert_equal 404, error.http_status + assert_includes error.hint, "Adminland" + end + + def test_error_from_response_404_account_inactive + error = Basecamp.error_from_response(404, nil, headers: { "Reason" => "Account Inactive" }) + + assert_instance_of Basecamp::NotFoundError, error + assert_equal "Account is inactive", error.message + assert_includes error.hint, "expired trial" + end + + def test_error_from_response_404_no_reason_header + error = Basecamp.error_from_response(404, nil, headers: {}) + + assert_instance_of Basecamp::NotFoundError, error + end end diff --git a/ruby/test/basecamp/http_test.rb b/ruby/test/basecamp/http_test.rb index a49151e16..4ba6de237 100644 --- a/ruby/test/basecamp/http_test.rb +++ b/ruby/test/basecamp/http_test.rb @@ -213,6 +213,28 @@ def test_404_raises_not_found_error end end + def test_404_with_api_disabled_reason_raises_api_disabled_error + stub_request(:get, "https://3.basecampapi.com/test.json") + .to_return(status: 404, body: "", headers: { "Reason" => "API Disabled" }) + + error = assert_raises(Basecamp::ApiDisabledError) do + @http.get("/test.json") + end + assert_equal 404, error.http_status + assert_includes error.hint, "Adminland" + end + + def test_404_with_account_inactive_reason_raises_not_found_error + stub_request(:get, "https://3.basecampapi.com/test.json") + .to_return(status: 404, body: "", headers: { "Reason" => "Account Inactive" }) + + error = assert_raises(Basecamp::NotFoundError) do + @http.get("/test.json") + end + assert_equal "Account is inactive", error.message + assert_includes error.hint, "expired trial" + end + def test_429_raises_rate_limit_error stub_request(:get, "https://3.basecampapi.com/test.json") .to_return(status: 429, body: "{}", headers: { "Retry-After" => "30" }) diff --git a/swift/Sources/Basecamp/BasecampError.swift b/swift/Sources/Basecamp/BasecampError.swift index ba76ce3bf..70615c00e 100644 --- a/swift/Sources/Basecamp/BasecampError.swift +++ b/swift/Sources/Basecamp/BasecampError.swift @@ -46,6 +46,9 @@ public enum BasecampError: Error, Sendable, LocalizedError { /// Multiple matches found for a name or identifier. case ambiguous(resource: String, matches: [String], hint: String?) + /// API access disabled by account administrator (HTTP 404 with Reason: API Disabled header). + case apiDisabled(message: String, hint: String?, requestId: String?) + /// Client usage error (invalid arguments, bad configuration). case usage(message: String, hint: String?) @@ -58,6 +61,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .network: true case .api(_, let status, _, _): status.map { $0 >= 500 } ?? false case .ambiguous: false + case .apiDisabled: false default: false } } @@ -68,6 +72,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth: 401 case .forbidden: 403 case .notFound: 404 + case .apiDisabled: 404 case .rateLimit: 429 case .validation(_, let status, _, _): status case .api(_, let status, _, _): status @@ -89,6 +94,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .api: 7 case .ambiguous: 8 case .validation: 9 + case .apiDisabled: 10 } } @@ -98,6 +104,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(_, let hint, _): hint case .forbidden(_, let hint, _): hint case .notFound(_, let hint, _): hint + case .apiDisabled(_, let hint, _): hint case .rateLimit(_, _, let hint, _): hint case .network: "Check your network connection" case .api(_, _, let hint, _): hint @@ -113,6 +120,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(let msg, _, _): msg case .forbidden(let msg, _, _): msg case .notFound(let msg, _, _): msg + case .apiDisabled(let msg, _, _): msg case .rateLimit(let msg, _, _, _): msg case .network(let msg, _): msg case .api(let msg, _, _, _): msg @@ -128,6 +136,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(_, _, let id): id case .forbidden(_, _, let id): id case .notFound(_, _, let id): id + case .apiDisabled(_, _, let id): id case .rateLimit(_, _, _, let id): id case .api(_, _, _, let id): id case .ambiguous: nil @@ -167,6 +176,21 @@ public enum BasecampError: Error, Sendable, LocalizedError { case 403: return .forbidden(message: message, hint: hint, requestId: requestId) case 404: + let reason = headers["Reason"] + if reason == "API Disabled" { + return .apiDisabled( + message: "API access is disabled for this account", + hint: "An administrator can re-enable it in Adminland under Manage API access", + requestId: requestId + ) + } + if reason == "Account Inactive" { + return .notFound( + message: "Account is inactive", + hint: "The account may have an expired trial or be suspended", + requestId: requestId + ) + } return .notFound(message: message, hint: hint, requestId: requestId) case 429: let retryAfter = parseRetryAfter(headers["Retry-After"]) diff --git a/swift/Tests/BasecampTests/ErrorTests.swift b/swift/Tests/BasecampTests/ErrorTests.swift index 9ad4a9a6f..5925b9182 100644 --- a/swift/Tests/BasecampTests/ErrorTests.swift +++ b/swift/Tests/BasecampTests/ErrorTests.swift @@ -75,6 +75,20 @@ final class ErrorTests: XCTestCase { XCTAssertEqual(error.hint, "Did you mean: Project A, Project B") } + func testApiDisabledErrorProperties() { + let error = BasecampError.apiDisabled( + message: "API access is disabled", + hint: "Contact admin", + requestId: "req-1" + ) + XCTAssertEqual(error.httpStatusCode, 404) + XCTAssertEqual(error.exitCode, 10) + XCTAssertFalse(error.isRetryable) + XCTAssertEqual(error.message, "API access is disabled") + XCTAssertEqual(error.hint, "Contact admin") + XCTAssertEqual(error.requestId, "req-1") + } + func testUsageErrorProperties() { let error = BasecampError.usage(message: "Bad argument", hint: "Use --flag") XCTAssertNil(error.httpStatusCode) @@ -103,6 +117,33 @@ final class ErrorTests: XCTestCase { if case .notFound = error { } else { XCTFail("Expected .notFound") } } + func testFromHTTPResponse404APIDisabled() { + let error = BasecampError.fromHTTPResponse( + status: 404, data: nil, headers: ["Reason": "API Disabled"], requestId: "req-1" + ) + if case .apiDisabled(let message, let hint, let requestId) = error { + XCTAssertTrue(message.contains("disabled")) + XCTAssertNotNil(hint) + XCTAssertTrue(hint!.contains("Adminland")) + XCTAssertEqual(requestId, "req-1") + } else { + XCTFail("Expected .apiDisabled, got \(error)") + } + } + + func testFromHTTPResponse404AccountInactive() { + let error = BasecampError.fromHTTPResponse( + status: 404, data: nil, headers: ["Reason": "Account Inactive"], requestId: nil + ) + if case .notFound(let message, let hint, _) = error { + XCTAssertTrue(message.contains("inactive")) + XCTAssertNotNil(hint) + XCTAssertTrue(hint!.contains("expired trial")) + } else { + XCTFail("Expected .notFound with account inactive, got \(error)") + } + } + func testFromHTTPResponse429() { let error = BasecampError.fromHTTPResponse( status: 429, data: nil, headers: ["Retry-After": "30"], requestId: nil diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index fdafad8e2..cfb55c1c1 100644 --- a/typescript/src/errors.ts +++ b/typescript/src/errors.ts @@ -49,6 +49,7 @@ export type ErrorCode = | "ambiguous" | "network" | "api_error" + | "api_disabled" | "usage"; /** @@ -83,6 +84,7 @@ const EXIT_CODES: Record = { api_error: 7, // API error ambiguous: 8, // Multiple matches found validation: 9, // Validation error (HTTP 400/422) + api_disabled: 10, // API access disabled for account }; /** @@ -258,6 +260,28 @@ export const Errors = { httpStatus, ...options, }), + + /** + * Creates an API disabled error (404 with Reason: API Disabled header). + * Thrown when an account administrator has disabled public API access. + */ + apiDisabled: (requestId?: string): BasecampError => + new BasecampError("api_disabled", "API access is disabled for this account", { + hint: "An administrator can re-enable it in Adminland under Manage API access", + httpStatus: 404, + requestId, + }), + + /** + * Creates an account inactive error (404 with Reason: Account Inactive header). + * Thrown when the account has an expired trial or is suspended. + */ + accountInactive: (requestId?: string): BasecampError => + new BasecampError("not_found", "Account is inactive", { + hint: "The account may have an expired trial or be suspended", + httpStatus: 404, + requestId, + }), }; /** @@ -295,8 +319,16 @@ export async function errorFromResponse( return new BasecampError("auth_required", message, { httpStatus, hint, requestId }); case 403: return new BasecampError("forbidden", message, { httpStatus, hint, requestId }); - case 404: + case 404: { + const reason = response.headers.get("Reason"); + if (reason === "API Disabled") { + return Errors.apiDisabled(requestId); + } + if (reason === "Account Inactive") { + return Errors.accountInactive(requestId); + } return new BasecampError("not_found", message, { httpStatus, hint, requestId }); + } case 429: return new BasecampError("rate_limit", message, { httpStatus, diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts index 9c51307c1..e6695ac4c 100644 --- a/typescript/tests/errors.test.ts +++ b/typescript/tests/errors.test.ts @@ -54,6 +54,7 @@ describe("BasecampError", () => { api_error: 7, ambiguous: 8, validation: 9, + api_disabled: 10, }; for (const [code, expected] of Object.entries(codes)) { @@ -214,6 +215,37 @@ describe("Errors factory", () => { expect(error.requestId).toBe("req-789"); }); }); + + describe("apiDisabled", () => { + it("should create an API disabled error", () => { + const error = Errors.apiDisabled(); + expect(error.code).toBe("api_disabled"); + expect(error.httpStatus).toBe(404); + expect(error.exitCode).toBe(10); + expect(error.message).toContain("disabled"); + expect(error.hint).toContain("Adminland"); + }); + + it("should include requestId", () => { + const error = Errors.apiDisabled("req-123"); + expect(error.requestId).toBe("req-123"); + }); + }); + + describe("accountInactive", () => { + it("should create an account inactive error", () => { + const error = Errors.accountInactive(); + expect(error.code).toBe("not_found"); + expect(error.httpStatus).toBe(404); + expect(error.message).toContain("inactive"); + expect(error.hint).toContain("expired trial"); + }); + + it("should include requestId", () => { + const error = Errors.accountInactive("req-456"); + expect(error.requestId).toBe("req-456"); + }); + }); }); describe("errorFromResponse", () => { @@ -252,6 +284,46 @@ describe("errorFromResponse", () => { expect(error.httpStatus).toBe(404); }); + it("should create api_disabled error from 404 with Reason: API Disabled header", async () => { + const response = new Response(null, { + status: 404, + headers: { "Reason": "API Disabled" }, + }); + + const error = await errorFromResponse(response); + + expect(error.code).toBe("api_disabled"); + expect(error.httpStatus).toBe(404); + expect(error.exitCode).toBe(10); + expect(error.hint).toContain("Adminland"); + }); + + it("should create account inactive error from 404 with Reason: Account Inactive header", async () => { + const response = new Response(null, { + status: 404, + headers: { "Reason": "Account Inactive" }, + }); + + const error = await errorFromResponse(response); + + expect(error.code).toBe("not_found"); + expect(error.httpStatus).toBe(404); + expect(error.message).toContain("inactive"); + expect(error.hint).toContain("expired trial"); + }); + + it("should preserve requestId on API Disabled error", async () => { + const response = new Response(null, { + status: 404, + headers: { "Reason": "API Disabled" }, + }); + + const error = await errorFromResponse(response, "req-xyz"); + + expect(error.code).toBe("api_disabled"); + expect(error.requestId).toBe("req-xyz"); + }); + it("should create rate limit error from 429 response", async () => { const response = new Response(null, { status: 429, From 47477a587f69d2b6b3e91f24e6bb2caaf0f87c72 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 30 Mar 2026 08:25:38 -0400 Subject: [PATCH 3/4] Preserve Swift and Kotlin error compatibility for API-disabled 404s --- go/README.md | 1 + kotlin/README.md | 3 + .../com/basecamp/sdk/BasecampException.kt | 29 ++++--- .../kotlin/com/basecamp/sdk/ErrorTest.kt | 22 +++-- python/README.md | 1 + swift/README.md | 3 + swift/Sources/Basecamp/BasecampError.swift | 84 +++++++++++++------ swift/Tests/BasecampTests/ErrorTests.swift | 55 ++++++++++-- typescript/README.md | 1 + 9 files changed, 144 insertions(+), 55 deletions(-) diff --git a/go/README.md b/go/README.md index 64ddd1388..775115519 100644 --- a/go/README.md +++ b/go/README.md @@ -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 diff --git a/kotlin/README.md b/kotlin/README.md index c42d230e5..b4b923a67 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -340,6 +340,7 @@ try { } // Common properties available on all subclasses + println("Code: ${e.code}") println("Hint: ${e.hint}") println("Retryable: ${e.retryable}") @@ -362,6 +363,8 @@ try { | `Validation` | 400, 422 | 9 | Invalid request data | | `Usage` | - | 1 | Configuration or argument error | +`Reason: API Disabled` responses are surfaced as `NotFound`, with `e.code == BasecampException.CODE_API_DISABLED` and exit code `10`, so existing exhaustive `when` expressions remain source-compatible. + ## Observability ### Console Logging diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index dbf9c7813..54120819f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -39,6 +39,9 @@ sealed class BasecampException( /** Exit code for CLI applications (matches Go/TS/Ruby SDKs). */ val exitCode: Int get() = exitCodeFor(code) + /** Whether this error represents account-level public API access being disabled. */ + val isApiDisabled: Boolean get() = code == CODE_API_DISABLED + /** Authentication error (401). */ class Auth( message: String = "Authentication required", @@ -61,7 +64,8 @@ sealed class BasecampException( hint: String? = null, requestId: String? = null, cause: Throwable? = null, - ) : BasecampException(message, CODE_NOT_FOUND, hint, 404, false, requestId, cause) + code: String = CODE_NOT_FOUND, + ) : BasecampException(message, code, hint, 404, false, requestId, cause) /** Rate limit error (429). Retryable with optional Retry-After. */ class RateLimit( @@ -108,13 +112,6 @@ sealed class BasecampException( "Did you mean: ${matches.joinToString(", ")}" else "Be more specific", ) : BasecampException("Ambiguous $resource", CODE_AMBIGUOUS, hint) - /** API access disabled by account administrator (404 with Reason: API Disabled header). */ - class ApiDisabled( - message: String = "API access is disabled for this account", - hint: String? = "An administrator can re-enable it in Adminland under Manage API access", - requestId: String? = null, - ) : BasecampException(message, CODE_API_DISABLED, hint, 404, false, requestId) - /** Usage error (bad arguments, configuration errors). */ class Usage( message: String, @@ -145,6 +142,11 @@ sealed class BasecampException( private const val EXIT_VALIDATION = 9 private const val EXIT_API_DISABLED = 10 + private const val API_DISABLED_MESSAGE = "API access is disabled for this account" + private const val API_DISABLED_HINT = "An administrator can re-enable it in Adminland under Manage API access" + private const val ACCOUNT_INACTIVE_MESSAGE = "Account is inactive" + private const val ACCOUNT_INACTIVE_HINT = "The account may have an expired trial or be suspended" + /** Maps an error code to a CLI exit code. */ fun exitCodeFor(code: String): Int = when (code) { CODE_USAGE -> EXIT_USAGE @@ -182,10 +184,15 @@ sealed class BasecampException( 401 -> Auth(msg, hint, requestId) 403 -> Forbidden(msg, hint, requestId) 404 -> when (reason) { - "API Disabled" -> ApiDisabled(requestId = requestId) + "API Disabled" -> NotFound( + API_DISABLED_MESSAGE, + API_DISABLED_HINT, + requestId, + code = CODE_API_DISABLED, + ) "Account Inactive" -> NotFound( - "Account is inactive", - "The account may have an expired trial or be suspended", + ACCOUNT_INACTIVE_MESSAGE, + ACCOUNT_INACTIVE_HINT, requestId, ) else -> NotFound(msg, hint, requestId) diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 58df69a3b..55a2a1d9d 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -32,6 +32,7 @@ class ErrorTest { assertEquals(2, e.exitCode) assertEquals("not_found", e.code) assertEquals(404, e.httpStatus) + assertFalse(e.isApiDisabled) } @Test @@ -110,11 +111,13 @@ class ErrorTest { @Test fun fromHttpStatusMaps404ApiDisabled() { val e = BasecampException.fromHttpStatus(404, "Not found", reason = "API Disabled") - assertIs(e) + assertIs(e) assertEquals("api_disabled", e.code) assertEquals(404, e.httpStatus) assertEquals(10, e.exitCode) assertFalse(e.retryable) + assertTrue(e.isApiDisabled) + assertEquals("API access is disabled for this account", e.message) assertTrue(e.hint?.contains("Adminland") == true) } @@ -124,6 +127,7 @@ class ErrorTest { assertIs(e) assertEquals("Account is inactive", e.message) assertTrue(e.hint?.contains("expired trial") == true) + assertEquals("not_found", e.code) } @Test @@ -136,19 +140,23 @@ class ErrorTest { @Test fun fromHttpStatusMaps404ApiDisabledPreservesRequestId() { val e = BasecampException.fromHttpStatus(404, "Not found", requestId = "req-123", reason = "API Disabled") - assertIs(e) + assertIs(e) assertEquals("req-123", e.requestId) + assertTrue(e.isApiDisabled) } @Test - fun apiDisabledErrorProperties() { - val e = BasecampException.ApiDisabled() + fun apiDisabledNotFoundCanBeConstructedWithoutNewSubtype() { + val e = BasecampException.NotFound( + message = "API access is disabled for this account", + hint = "An administrator can re-enable it in Adminland under Manage API access", + code = BasecampException.CODE_API_DISABLED, + ) assertEquals("api_disabled", e.code) assertEquals(404, e.httpStatus) assertEquals(10, e.exitCode) assertFalse(e.retryable) - assertTrue(e.message!!.contains("disabled")) - assertTrue(e.hint?.contains("Adminland") == true) + assertTrue(e.isApiDisabled) } @Test @@ -196,7 +204,6 @@ class ErrorTest { BasecampException.Api("error", 500), BasecampException.Ambiguous("project"), BasecampException.Validation("invalid"), - BasecampException.ApiDisabled(), BasecampException.Usage("bad arg"), ) @@ -211,7 +218,6 @@ class ErrorTest { is BasecampException.Api -> "api" is BasecampException.Ambiguous -> "ambiguous" is BasecampException.Validation -> "validation" - is BasecampException.ApiDisabled -> "api_disabled" is BasecampException.Usage -> "usage" } assertTrue(code.isNotEmpty()) diff --git a/python/README.md b/python/README.md index c126123d3..f2a6c5261 100644 --- a/python/README.md +++ b/python/README.md @@ -353,6 +353,7 @@ All exceptions inherit from `BasecampError`: | `ApiError` | `api_error` | 5xx, other | Yes for 500/502/503/504; No otherwise | | `AmbiguousError` | `ambiguous` | - | No | | `ValidationError` | `validation` | 400, 422 | No | +| `ApiDisabledError` | `api_disabled` | 404 | No | Every `BasecampError` provides: - `code` - `ErrorCode` enum value diff --git a/swift/README.md b/swift/README.md index 98e254078..0d5195780 100644 --- a/swift/README.md +++ b/swift/README.md @@ -284,6 +284,7 @@ do { } // Common properties available on all cases + print("Code: \(error.code)") print("Hint: \(error.hint ?? "none")") print("Retryable: \(error.isRetryable)") @@ -306,6 +307,8 @@ do { | `.validation` | 400, 422 | 9 | Invalid request data | | `.usage` | - | 1 | Configuration or argument error | +`Reason: API Disabled` responses are surfaced as `.notFound`, with `error.code == "api_disabled"` and exit code `10`, so existing exhaustive `switch` statements remain source-compatible. + ## Observability ### Custom Hooks diff --git a/swift/Sources/Basecamp/BasecampError.swift b/swift/Sources/Basecamp/BasecampError.swift index 70615c00e..6600d9d83 100644 --- a/swift/Sources/Basecamp/BasecampError.swift +++ b/swift/Sources/Basecamp/BasecampError.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Structured error type for Basecamp API errors. /// @@ -46,12 +49,14 @@ public enum BasecampError: Error, Sendable, LocalizedError { /// Multiple matches found for a name or identifier. case ambiguous(resource: String, matches: [String], hint: String?) - /// API access disabled by account administrator (HTTP 404 with Reason: API Disabled header). - case apiDisabled(message: String, hint: String?, requestId: String?) - /// Client usage error (invalid arguments, bad configuration). case usage(message: String, hint: String?) + private static let apiDisabledMessage = "API access is disabled for this account" + private static let apiDisabledHint = "An administrator can re-enable it in Adminland under Manage API access" + private static let accountInactiveMessage = "Account is inactive" + private static let accountInactiveHint = "The account may have an expired trial or be suspended" + // MARK: - Computed Properties /// Whether this error can be retried. @@ -61,18 +66,37 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .network: true case .api(_, let status, _, _): status.map { $0 >= 500 } ?? false case .ambiguous: false - case .apiDisabled: false default: false } } + /// Stable SDK error code, matching the other Basecamp SDKs. + public var code: String { + switch self { + case .auth: "auth_required" + case .forbidden: "forbidden" + case .notFound(let message, _, _): + message == Self.apiDisabledMessage ? "api_disabled" : "not_found" + case .rateLimit: "rate_limit" + case .network: "network" + case .api: "api_error" + case .validation: "validation" + case .ambiguous: "ambiguous" + case .usage: "usage" + } + } + + /// Whether this error represents account-level public API access being disabled. + public var isAPIDisabled: Bool { + code == "api_disabled" + } + /// The HTTP status code, if applicable. public var httpStatusCode: Int? { switch self { case .auth: 401 case .forbidden: 403 case .notFound: 404 - case .apiDisabled: 404 case .rateLimit: 429 case .validation(_, let status, _, _): status case .api(_, let status, _, _): status @@ -82,19 +106,20 @@ public enum BasecampError: Error, Sendable, LocalizedError { } } - /// Exit code for CLI applications, matching Go/TS conventions. + /// Exit code for CLI applications, matching Go/TS/Ruby conventions. public var exitCode: Int { - switch self { - case .usage: 1 - case .notFound: 2 - case .auth: 3 - case .forbidden: 4 - case .rateLimit: 5 - case .network: 6 - case .api: 7 - case .ambiguous: 8 - case .validation: 9 - case .apiDisabled: 10 + switch code { + case "usage": 1 + case "not_found": 2 + case "auth_required": 3 + case "forbidden": 4 + case "rate_limit": 5 + case "network": 6 + case "api_error": 7 + case "ambiguous": 8 + case "validation": 9 + case "api_disabled": 10 + default: 7 } } @@ -104,7 +129,6 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(_, let hint, _): hint case .forbidden(_, let hint, _): hint case .notFound(_, let hint, _): hint - case .apiDisabled(_, let hint, _): hint case .rateLimit(_, _, let hint, _): hint case .network: "Check your network connection" case .api(_, _, let hint, _): hint @@ -120,7 +144,6 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(let msg, _, _): msg case .forbidden(let msg, _, _): msg case .notFound(let msg, _, _): msg - case .apiDisabled(let msg, _, _): msg case .rateLimit(let msg, _, _, _): msg case .network(let msg, _): msg case .api(let msg, _, _, _): msg @@ -136,7 +159,6 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .auth(_, _, let id): id case .forbidden(_, _, let id): id case .notFound(_, _, let id): id - case .apiDisabled(_, _, let id): id case .rateLimit(_, _, _, let id): id case .api(_, _, _, let id): id case .ambiguous: nil @@ -176,24 +198,24 @@ public enum BasecampError: Error, Sendable, LocalizedError { case 403: return .forbidden(message: message, hint: hint, requestId: requestId) case 404: - let reason = headers["Reason"] + let reason = headerValue(named: "Reason", in: headers) if reason == "API Disabled" { - return .apiDisabled( - message: "API access is disabled for this account", - hint: "An administrator can re-enable it in Adminland under Manage API access", + return .notFound( + message: apiDisabledMessage, + hint: apiDisabledHint, requestId: requestId ) } if reason == "Account Inactive" { return .notFound( - message: "Account is inactive", - hint: "The account may have an expired trial or be suspended", + message: accountInactiveMessage, + hint: accountInactiveHint, requestId: requestId ) } return .notFound(message: message, hint: hint, requestId: requestId) case 429: - let retryAfter = parseRetryAfter(headers["Retry-After"]) + let retryAfter = parseRetryAfter(headerValue(named: "Retry-After", in: headers)) let retryHint = retryAfter.map { "Retry after \($0) seconds" } ?? hint return .rateLimit( message: message, retryAfterSeconds: retryAfter, @@ -227,6 +249,14 @@ public enum BasecampError: Error, Sendable, LocalizedError { return String(s.prefix(maxMessageLength - 3)) + "..." } + private static func headerValue(named name: String, in headers: [String: String]) -> String? { + if let value = headers[name] { + return value + } + let lowercasedName = name.lowercased() + return headers.first { $0.key.lowercased() == lowercasedName }?.value + } + /// Parses a Retry-After header value (seconds or HTTP-date). static func parseRetryAfter(_ value: String?) -> Int? { guard let value, !value.isEmpty else { return nil } diff --git a/swift/Tests/BasecampTests/ErrorTests.swift b/swift/Tests/BasecampTests/ErrorTests.swift index 5925b9182..b7acee3f8 100644 --- a/swift/Tests/BasecampTests/ErrorTests.swift +++ b/swift/Tests/BasecampTests/ErrorTests.swift @@ -7,6 +7,7 @@ final class ErrorTests: XCTestCase { func testAuthErrorProperties() { let error = BasecampError.auth(message: "Unauthorized", hint: "Check token", requestId: "req-1") + XCTAssertEqual(error.code, "auth_required") XCTAssertEqual(error.httpStatusCode, 401) XCTAssertEqual(error.exitCode, 3) XCTAssertFalse(error.isRetryable) @@ -17,6 +18,7 @@ final class ErrorTests: XCTestCase { func testForbiddenErrorProperties() { let error = BasecampError.forbidden(message: "Denied", hint: nil, requestId: nil) + XCTAssertEqual(error.code, "forbidden") XCTAssertEqual(error.httpStatusCode, 403) XCTAssertEqual(error.exitCode, 4) XCTAssertFalse(error.isRetryable) @@ -24,6 +26,8 @@ final class ErrorTests: XCTestCase { func testNotFoundErrorProperties() { let error = BasecampError.notFound(message: "Not found", hint: nil, requestId: nil) + XCTAssertEqual(error.code, "not_found") + XCTAssertFalse(error.isAPIDisabled) XCTAssertEqual(error.httpStatusCode, 404) XCTAssertEqual(error.exitCode, 2) XCTAssertFalse(error.isRetryable) @@ -34,6 +38,7 @@ final class ErrorTests: XCTestCase { message: "Rate limited", retryAfterSeconds: 30, hint: "Retry after 30 seconds", requestId: nil ) + XCTAssertEqual(error.code, "rate_limit") XCTAssertEqual(error.httpStatusCode, 429) XCTAssertEqual(error.exitCode, 5) XCTAssertTrue(error.isRetryable) @@ -41,6 +46,7 @@ final class ErrorTests: XCTestCase { func testNetworkErrorProperties() { let error = BasecampError.network(message: "Connection failed", cause: nil) + XCTAssertEqual(error.code, "network") XCTAssertNil(error.httpStatusCode) XCTAssertEqual(error.exitCode, 6) XCTAssertTrue(error.isRetryable) @@ -49,6 +55,7 @@ final class ErrorTests: XCTestCase { func testApiErrorProperties() { let error = BasecampError.api(message: "Server error", httpStatus: 500, hint: nil, requestId: nil) + XCTAssertEqual(error.code, "api_error") XCTAssertEqual(error.httpStatusCode, 500) XCTAssertEqual(error.exitCode, 7) XCTAssertTrue(error.isRetryable) @@ -61,6 +68,7 @@ final class ErrorTests: XCTestCase { func testValidationErrorProperties() { let error = BasecampError.validation(message: "Invalid", httpStatus: 422, hint: nil, requestId: nil) + XCTAssertEqual(error.code, "validation") XCTAssertEqual(error.httpStatusCode, 422) XCTAssertEqual(error.exitCode, 9) XCTAssertFalse(error.isRetryable) @@ -68,6 +76,7 @@ final class ErrorTests: XCTestCase { func testAmbiguousErrorProperties() { let error = BasecampError.ambiguous(resource: "project", matches: ["Project A", "Project B"], hint: "Did you mean: Project A, Project B") + XCTAssertEqual(error.code, "ambiguous") XCTAssertNil(error.httpStatusCode) XCTAssertEqual(error.exitCode, 8) XCTAssertFalse(error.isRetryable) @@ -76,21 +85,26 @@ final class ErrorTests: XCTestCase { } func testApiDisabledErrorProperties() { - let error = BasecampError.apiDisabled( - message: "API access is disabled", - hint: "Contact admin", - requestId: "req-1" + let error = BasecampError.fromHTTPResponse( + status: 404, data: nil, headers: ["Reason": "API Disabled"], requestId: "req-1" ) + if case .notFound(let message, let hint, let requestId) = error { + XCTAssertEqual(message, "API access is disabled for this account") + XCTAssertEqual(hint, "An administrator can re-enable it in Adminland under Manage API access") + XCTAssertEqual(requestId, "req-1") + } else { + XCTFail("Expected .notFound, got \(error)") + } + XCTAssertEqual(error.code, "api_disabled") + XCTAssertTrue(error.isAPIDisabled) XCTAssertEqual(error.httpStatusCode, 404) XCTAssertEqual(error.exitCode, 10) XCTAssertFalse(error.isRetryable) - XCTAssertEqual(error.message, "API access is disabled") - XCTAssertEqual(error.hint, "Contact admin") - XCTAssertEqual(error.requestId, "req-1") } func testUsageErrorProperties() { let error = BasecampError.usage(message: "Bad argument", hint: "Use --flag") + XCTAssertEqual(error.code, "usage") XCTAssertNil(error.httpStatusCode) XCTAssertEqual(error.exitCode, 1) XCTAssertFalse(error.isRetryable) @@ -115,20 +129,31 @@ final class ErrorTests: XCTestCase { func testFromHTTPResponse404() { let error = BasecampError.fromHTTPResponse(status: 404, data: nil, headers: [:], requestId: nil) if case .notFound = error { } else { XCTFail("Expected .notFound") } + XCTAssertEqual(error.code, "not_found") } func testFromHTTPResponse404APIDisabled() { let error = BasecampError.fromHTTPResponse( status: 404, data: nil, headers: ["Reason": "API Disabled"], requestId: "req-1" ) - if case .apiDisabled(let message, let hint, let requestId) = error { + if case .notFound(let message, let hint, let requestId) = error { XCTAssertTrue(message.contains("disabled")) XCTAssertNotNil(hint) XCTAssertTrue(hint!.contains("Adminland")) XCTAssertEqual(requestId, "req-1") } else { - XCTFail("Expected .apiDisabled, got \(error)") + XCTFail("Expected .notFound, got \(error)") } + XCTAssertEqual(error.code, "api_disabled") + XCTAssertEqual(error.exitCode, 10) + } + + func testFromHTTPResponse404APIDisabledLowercaseHeaderName() { + let error = BasecampError.fromHTTPResponse( + status: 404, data: nil, headers: ["reason": "API Disabled"], requestId: nil + ) + XCTAssertEqual(error.code, "api_disabled") + XCTAssertTrue(error.isAPIDisabled) } func testFromHTTPResponse404AccountInactive() { @@ -142,6 +167,7 @@ final class ErrorTests: XCTestCase { } else { XCTFail("Expected .notFound with account inactive, got \(error)") } + XCTAssertEqual(error.code, "not_found") } func testFromHTTPResponse429() { @@ -155,6 +181,17 @@ final class ErrorTests: XCTestCase { } } + func testFromHTTPResponse429LowercaseRetryAfterHeaderName() { + let error = BasecampError.fromHTTPResponse( + status: 429, data: nil, headers: ["retry-after": "30"], requestId: nil + ) + if case .rateLimit(_, let retryAfter, _, _) = error { + XCTAssertEqual(retryAfter, 30) + } else { + XCTFail("Expected .rateLimit") + } + } + func testFromHTTPResponse422() { let body = try! JSONSerialization.data(withJSONObject: ["error": "Name is required"]) let error = BasecampError.fromHTTPResponse(status: 422, data: body, headers: [:], requestId: nil) diff --git a/typescript/README.md b/typescript/README.md index a84821a49..adb72b3d9 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -353,6 +353,7 @@ try { | `api_error` | 5xx | 7 | Server error | | `ambiguous` | - | 8 | Multiple matches found | | `validation` | 400, 422 | 9 | Invalid request data | +| `api_disabled` | 404 | 10 | Public API access disabled for the account | | `usage` | - | 1 | Configuration or argument error | ## Retry Behavior From 0779ef249777da4e330a2fcb11248a5883cdd8b3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 30 Mar 2026 09:20:27 -0400 Subject: [PATCH 4/4] Preserve Kotlin NotFound constructor compatibility --- go/pkg/basecamp/client.go | 3 ++ go/pkg/basecamp/client_test.go | 28 +++++++++++++ .../com/basecamp/sdk/BasecampException.kt | 41 +++++++++++++------ .../kotlin/com/basecamp/sdk/ErrorTest.kt | 9 ++-- ...asecampExceptionBinaryCompatibilityTest.kt | 31 ++++++++++++++ ruby/lib/basecamp.rb | 6 ++- ruby/test/basecamp/errors_test.rb | 21 +++++++++- swift/Sources/Basecamp/BasecampError.swift | 15 +++++-- swift/Tests/BasecampTests/ErrorTests.swift | 10 +++++ 9 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 kotlin/sdk/src/jvmTest/kotlin/com/basecamp/sdk/BasecampExceptionBinaryCompatibilityTest.kt diff --git a/go/pkg/basecamp/client.go b/go/pkg/basecamp/client.go index 7c799be74..a39a76368 100644 --- a/go/pkg/basecamp/client.go +++ b/go/pkg/basecamp/client.go @@ -782,6 +782,9 @@ func (c *Client) singleRequest(ctx context.Context, method, url string, body any case http.StatusNotFound: // 404 if reasonErr := checkReasonHeader(resp); reasonErr != nil { + if reasonErr.RequestID == "" { + reasonErr.RequestID = resp.Header.Get("X-Request-Id") + } return nil, reasonErr } return nil, ErrNotFound("Resource", url) diff --git a/go/pkg/basecamp/client_test.go b/go/pkg/basecamp/client_test.go index ea086d13f..a5aa441aa 100644 --- a/go/pkg/basecamp/client_test.go +++ b/go/pkg/basecamp/client_test.go @@ -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") + } +} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index 54120819f..d30ac794c 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -59,13 +59,22 @@ sealed class BasecampException( ) : BasecampException(message, CODE_FORBIDDEN, hint, 403, false, requestId, cause) /** Not found error (404). */ - class NotFound( - message: String = "Resource not found", - hint: String? = null, - requestId: String? = null, - cause: Throwable? = null, - code: String = CODE_NOT_FOUND, - ) : BasecampException(message, code, hint, 404, false, requestId, cause) + class NotFound internal constructor( + message: String, + hint: String?, + requestId: String?, + cause: Throwable?, + code: String, + ) : BasecampException(message, code, hint, 404, false, requestId, cause) { + constructor() : this("Resource not found", null, null, null, CODE_NOT_FOUND) + + constructor( + message: String = "Resource not found", + hint: String? = null, + requestId: String? = null, + cause: Throwable? = null, + ) : this(message, hint, requestId, cause, CODE_NOT_FOUND) + } /** Rate limit error (429). Retryable with optional Retry-After. */ class RateLimit( @@ -162,6 +171,17 @@ sealed class BasecampException( else -> EXIT_API } + internal fun apiDisabledNotFound( + requestId: String? = null, + cause: Throwable? = null, + ): NotFound = NotFound( + API_DISABLED_MESSAGE, + API_DISABLED_HINT, + requestId, + cause, + CODE_API_DISABLED, + ) + /** Maximum length for error messages to prevent unbounded memory growth. */ private const val MAX_ERROR_MESSAGE_LENGTH = 500 @@ -184,12 +204,7 @@ sealed class BasecampException( 401 -> Auth(msg, hint, requestId) 403 -> Forbidden(msg, hint, requestId) 404 -> when (reason) { - "API Disabled" -> NotFound( - API_DISABLED_MESSAGE, - API_DISABLED_HINT, - requestId, - code = CODE_API_DISABLED, - ) + "API Disabled" -> apiDisabledNotFound(requestId) "Account Inactive" -> NotFound( ACCOUNT_INACTIVE_MESSAGE, ACCOUNT_INACTIVE_HINT, diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 55a2a1d9d..3393c9152 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -146,12 +146,9 @@ class ErrorTest { } @Test - fun apiDisabledNotFoundCanBeConstructedWithoutNewSubtype() { - val e = BasecampException.NotFound( - message = "API access is disabled for this account", - hint = "An administrator can re-enable it in Adminland under Manage API access", - code = BasecampException.CODE_API_DISABLED, - ) + fun apiDisabledNotFoundUsesNotFoundSubtypeWithoutNewPublicConstructor() { + val e = BasecampException.apiDisabledNotFound() + assertIs(e) assertEquals("api_disabled", e.code) assertEquals(404, e.httpStatus) assertEquals(10, e.exitCode) diff --git a/kotlin/sdk/src/jvmTest/kotlin/com/basecamp/sdk/BasecampExceptionBinaryCompatibilityTest.kt b/kotlin/sdk/src/jvmTest/kotlin/com/basecamp/sdk/BasecampExceptionBinaryCompatibilityTest.kt new file mode 100644 index 000000000..c05905f64 --- /dev/null +++ b/kotlin/sdk/src/jvmTest/kotlin/com/basecamp/sdk/BasecampExceptionBinaryCompatibilityTest.kt @@ -0,0 +1,31 @@ +package com.basecamp.sdk + +import kotlin.test.Test +import kotlin.test.assertTrue + +class BasecampExceptionBinaryCompatibilityTest { + + @Test + fun notFoundRetainsLegacyConstructors() { + val constructors = BasecampException.NotFound::class.java.declaredConstructors + + val hasLegacyFourArgumentConstructor = constructors.any { constructor -> + val parameterTypes = constructor.parameterTypes + parameterTypes.size == 4 + && parameterTypes[0] == String::class.java + && parameterTypes[1] == String::class.java + && parameterTypes[2] == String::class.java + && parameterTypes[3] == Throwable::class.java + } + val hasLegacyZeroArgumentConstructor = constructors.any { it.parameterTypes.isEmpty() } + + assertTrue( + hasLegacyFourArgumentConstructor, + "Expected legacy NotFound(String, String?, String?, Throwable?) constructor for binary compatibility", + ) + assertTrue( + hasLegacyZeroArgumentConstructor, + "Expected legacy zero-argument NotFound() constructor for binary compatibility", + ) + } +} diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb index c134ac47c..9840f2721 100644 --- a/ruby/lib/basecamp.rb +++ b/ruby/lib/basecamp.rb @@ -91,8 +91,9 @@ def self.client( # @return [Error] def self.error_from_response(status, body = nil, retry_after: nil, headers: {}) message = parse_error_message(body) || "Request failed" + request_id = headers["X-Request-Id"] || headers["x-request-id"] - case status + err = case status when 400, 422 ValidationError.new(message, http_status: status) when 401 @@ -117,6 +118,9 @@ def self.error_from_response(status, body = nil, retry_after: nil, headers: {}) else ApiError.from_status(status, message) end + + err.instance_variable_set(:@request_id, request_id) if request_id + err end # Extracts a filename from the last path segment of a URL. diff --git a/ruby/test/basecamp/errors_test.rb b/ruby/test/basecamp/errors_test.rb index 42a206799..0d6bb272c 100644 --- a/ruby/test/basecamp/errors_test.rb +++ b/ruby/test/basecamp/errors_test.rb @@ -175,19 +175,29 @@ def test_api_disabled_exit_code end def test_error_from_response_404_api_disabled - error = Basecamp.error_from_response(404, nil, headers: { "Reason" => "API Disabled" }) + error = Basecamp.error_from_response( + 404, + nil, + headers: { "Reason" => "API Disabled", "X-Request-Id" => "req-123" } + ) assert_instance_of Basecamp::ApiDisabledError, error assert_equal 404, error.http_status assert_includes error.hint, "Adminland" + assert_equal "req-123", error.request_id end def test_error_from_response_404_account_inactive - error = Basecamp.error_from_response(404, nil, headers: { "Reason" => "Account Inactive" }) + error = Basecamp.error_from_response( + 404, + nil, + headers: { "Reason" => "Account Inactive", "x-request-id" => "req-456" } + ) assert_instance_of Basecamp::NotFoundError, error assert_equal "Account is inactive", error.message assert_includes error.hint, "expired trial" + assert_equal "req-456", error.request_id end def test_error_from_response_404_no_reason_header @@ -195,4 +205,11 @@ def test_error_from_response_404_no_reason_header assert_instance_of Basecamp::NotFoundError, error end + + def test_error_from_response_sets_request_id_for_other_errors + error = Basecamp.error_from_response(401, nil, headers: { "X-Request-Id" => "req-789" }) + + assert_instance_of Basecamp::AuthError, error + assert_equal "req-789", error.request_id + end end diff --git a/swift/Sources/Basecamp/BasecampError.swift b/swift/Sources/Basecamp/BasecampError.swift index 6600d9d83..36af856a1 100644 --- a/swift/Sources/Basecamp/BasecampError.swift +++ b/swift/Sources/Basecamp/BasecampError.swift @@ -75,8 +75,8 @@ public enum BasecampError: Error, Sendable, LocalizedError { switch self { case .auth: "auth_required" case .forbidden: "forbidden" - case .notFound(let message, _, _): - message == Self.apiDisabledMessage ? "api_disabled" : "not_found" + case .notFound(let message, let hint, _): + isAPIDisabledNotFound(message: message, hint: hint) ? "api_disabled" : "not_found" case .rateLimit: "rate_limit" case .network: "network" case .api: "api_error" @@ -88,7 +88,12 @@ public enum BasecampError: Error, Sendable, LocalizedError { /// Whether this error represents account-level public API access being disabled. public var isAPIDisabled: Bool { - code == "api_disabled" + switch self { + case .notFound(let message, let hint, _): + isAPIDisabledNotFound(message: message, hint: hint) + default: + false + } } /// The HTTP status code, if applicable. @@ -257,6 +262,10 @@ public enum BasecampError: Error, Sendable, LocalizedError { return headers.first { $0.key.lowercased() == lowercasedName }?.value } + private func isAPIDisabledNotFound(message: String, hint: String?) -> Bool { + message == Self.apiDisabledMessage && hint == Self.apiDisabledHint + } + /// Parses a Retry-After header value (seconds or HTTP-date). static func parseRetryAfter(_ value: String?) -> Int? { guard let value, !value.isEmpty else { return nil } diff --git a/swift/Tests/BasecampTests/ErrorTests.swift b/swift/Tests/BasecampTests/ErrorTests.swift index b7acee3f8..dead984da 100644 --- a/swift/Tests/BasecampTests/ErrorTests.swift +++ b/swift/Tests/BasecampTests/ErrorTests.swift @@ -156,6 +156,16 @@ final class ErrorTests: XCTestCase { XCTAssertTrue(error.isAPIDisabled) } + func testCallerConstructedNotFoundWithMatchingMessageOnlyIsNotApiDisabled() { + let error = BasecampError.notFound( + message: "API access is disabled for this account", + hint: "Some other hint", + requestId: nil + ) + XCTAssertEqual(error.code, "not_found") + XCTAssertFalse(error.isAPIDisabled) + } + func testFromHTTPResponse404AccountInactive() { let error = BasecampError.fromHTTPResponse( status: 404, data: nil, headers: ["Reason": "Account Inactive"], requestId: nil