diff --git a/go/README.md b/go/README.md index 64ddd138..77511551 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/go/pkg/basecamp/client.go b/go/pkg/basecamp/client.go index 6f030133..a39a7636 100644 --- a/go/pkg/basecamp/client.go +++ b/go/pkg/basecamp/client.go @@ -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 { + if reasonErr.RequestID == "" { + reasonErr.RequestID = resp.Header.Get("X-Request-Id") + } + return nil, reasonErr + } return nil, ErrNotFound("Resource", url) case http.StatusInternalServerError: // 500 diff --git a/go/pkg/basecamp/client_test.go b/go/pkg/basecamp/client_test.go index ea086d13..a5aa441a 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/go/pkg/basecamp/errors.go b/go/pkg/basecamp/errors.go index 3bc768e5..7032320b 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 f04d41ef..74e96feb 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 b01e6262..f5020f71 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 4f3c5b2c..317c5e12 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 { diff --git a/kotlin/README.md b/kotlin/README.md index c42d230e..b4b923a6 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 6c9c6514..d30ac794 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", @@ -56,12 +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, - ) : BasecampException(message, CODE_NOT_FOUND, 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( @@ -123,6 +136,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 +149,12 @@ 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 + + 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) { @@ -147,9 +167,21 @@ sealed class BasecampException( CODE_API -> EXIT_API CODE_AMBIGUOUS -> EXIT_AMBIGUOUS CODE_VALIDATION -> EXIT_VALIDATION + CODE_API_DISABLED -> EXIT_API_DISABLED 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 @@ -165,12 +197,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" -> apiDisabledNotFound(requestId) + "Account Inactive" -> NotFound( + ACCOUNT_INACTIVE_MESSAGE, + ACCOUNT_INACTIVE_HINT, + 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 b02e44c8..ebbef313 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 ebfa630e..e9212799 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 1b09b851..3393c915 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 @@ -107,6 +108,54 @@ 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.isApiDisabled) + assertEquals("API access is disabled for this account", e.message) + 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) + assertEquals("not_found", e.code) + } + + @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) + assertTrue(e.isApiDisabled) + } + + @Test + fun apiDisabledNotFoundUsesNotFoundSubtypeWithoutNewPublicConstructor() { + val e = BasecampException.apiDisabledNotFound() + assertIs(e) + assertEquals("api_disabled", e.code) + assertEquals(404, e.httpStatus) + assertEquals(10, e.exitCode) + assertFalse(e.retryable) + assertTrue(e.isApiDisabled) + } + @Test fun fromHttpStatusMaps429ToRateLimit() { val e = BasecampException.fromHttpStatus(429, "Too many requests", retryAfterSeconds = 10) 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 00000000..c05905f6 --- /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/python/README.md b/python/README.md index c126123d..f2a6c526 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/python/src/basecamp/__init__.py b/python/src/basecamp/__init__.py index 738b9737..0f5dc235 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 139da5e8..cdebc186 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 47a9b68d..84e912de 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 57ec4690..bdd8795f 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 94f5899d..9840f272 100644 --- a/ruby/lib/basecamp.rb +++ b/ruby/lib/basecamp.rb @@ -89,10 +89,11 @@ 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" + 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 @@ -100,7 +101,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 @@ -110,6 +118,9 @@ def self.error_from_response(status, body = nil, retry_after: nil) 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/lib/basecamp/api_disabled_error.rb b/ruby/lib/basecamp/api_disabled_error.rb new file mode 100644 index 00000000..082c6321 --- /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 105683c0..26990fb7 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 b02ec16d..9f4a93f9 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 6c66ca6c..60a89e97 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 f52beb15..e6491493 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 9a5e71ff..c354e865 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 755c172c..0d6bb272 100644 --- a/ruby/test/basecamp/errors_test.rb +++ b/ruby/test/basecamp/errors_test.rb @@ -157,4 +157,59 @@ 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", "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", "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 + error = Basecamp.error_from_response(404, nil, headers: {}) + + 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/ruby/test/basecamp/http_test.rb b/ruby/test/basecamp/http_test.rb index a49151e1..4ba6de23 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/README.md b/swift/README.md index 98e25407..0d519578 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 ba76ce3b..36af856a 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. /// @@ -49,6 +52,11 @@ public enum BasecampError: Error, Sendable, LocalizedError { /// 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. @@ -62,6 +70,32 @@ public enum BasecampError: Error, Sendable, LocalizedError { } } + /// 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, let hint, _): + isAPIDisabledNotFound(message: message, hint: hint) ? "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 { + switch self { + case .notFound(let message, let hint, _): + isAPIDisabledNotFound(message: message, hint: hint) + default: + false + } + } + /// The HTTP status code, if applicable. public var httpStatusCode: Int? { switch self { @@ -77,18 +111,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 + 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 } } @@ -167,9 +203,24 @@ public enum BasecampError: Error, Sendable, LocalizedError { case 403: return .forbidden(message: message, hint: hint, requestId: requestId) case 404: + let reason = headerValue(named: "Reason", in: headers) + if reason == "API Disabled" { + return .notFound( + message: apiDisabledMessage, + hint: apiDisabledHint, + requestId: requestId + ) + } + if reason == "Account Inactive" { + return .notFound( + 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, @@ -203,6 +254,18 @@ 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 + } + + 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 9ad4a9a6..dead984d 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) @@ -75,8 +84,27 @@ final class ErrorTests: XCTestCase { XCTAssertEqual(error.hint, "Did you mean: Project A, Project B") } + func testApiDisabledErrorProperties() { + 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) + } + 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) @@ -101,6 +129,55 @@ 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 .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 .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 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 + ) + 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)") + } + XCTAssertEqual(error.code, "not_found") } func testFromHTTPResponse429() { @@ -114,6 +191,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 a84821a4..adb72b3d 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 diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index fdafad8e..cfb55c1c 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 9c51307c..e6695ac4 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,