diff --git a/README.md b/README.md index baa487d..685f4e9 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,36 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `VerifyOfflineLicense` | Verifies a signed `.lic` file locally, with no network call. | | `OfflineKeyID` | Returns the key ID Keymint computes for a public key PEM. | +## Error Handling + +API errors are returned as `*keymint.ApiError`. Branch on `ErrorCode`, a stable string such as `license_invalid_or_forbidden` or `rate_limited`, not on `Message`, which is written for people and may be reworded. See the [error code reference](https://keymint.dev/docs/api-reference/error-codes) for the full list. + +```go +res, err := client.ActivateKey(keymint.ActivateKeyParams{ + ProductID: productId, + LicenseKey: licenseKey, + HostID: keymint.String(hostId), +}) +var apiErr *keymint.ApiError +switch { +case err == nil: + fmt.Println("Activated:", res.Message) +case errors.As(err, &apiErr): + switch apiErr.ErrorCode { + case "license_invalid_or_forbidden": + // Rejected: ask the user to re-enter the key or contact support. + case "activation_limit_reached": + // Ask the user to deactivate another device. + case "rate_limited", "service_unavailable": + // No answer yet: keep the last known state and retry later. + default: + fmt.Printf("Keymint error %s (HTTP %d): %s\n", apiErr.ErrorCode, *apiErr.Status, apiErr.Message) + } +} +``` + +`ErrorCode` is empty when the response has none, for example a non-JSON body from a proxy, where `Code` is `-1`. `Code` (numeric) and `ErrorDetail` / `ErrorText` (the legacy `error` field) are still set as before. + ## Idempotency All mutating SDK methods support idempotency keys to safely retry requests in case of network drops. Pass a pointer to a `keymint.RequestOptions` struct as the optional variadic argument: diff --git a/Types.go b/Types.go index 6487e67..34ad7db 100644 --- a/Types.go +++ b/Types.go @@ -89,6 +89,9 @@ type ApiError struct { Message string `json:"message"` // Code is the API specific error code. Code int `json:"code"` + // ErrorCode is the stable string error code (e.g. "license_not_found"). + // Branch on this rather than on Message. Empty when the response has none. + ErrorCode string `json:"errorCode,omitempty"` // Status is the optional HTTP status code. Status *int `json:"status,omitempty"` // ErrorDetail holds the nested error envelope when `error` is an object. diff --git a/contract_test.go b/contract_test.go index d2eb9b0..afc85e2 100644 --- a/contract_test.go +++ b/contract_test.go @@ -353,3 +353,40 @@ func TestDeleteKeyReturnsNotFoundError(t *testing.T) { t.Fatalf("status not captured: %#v", apiErr.Status) } } + +func TestApiErrorsExposeErrorCode(t *testing.T) { + cases := []struct { + name string + status int + body string + wantCode int + wantErrorCode string + wantMessage string + }{ + {"with errorCode", http.StatusUnauthorized, `{"message":"Invalid license key or insufficient permissions","code":1,"errorCode":"license_invalid_or_forbidden"}`, 1, "license_invalid_or_forbidden", "Invalid license key or insufficient permissions"}, + {"with errorCode and error object", http.StatusBadRequest, `{"success":false,"message":"Field 'licenseKey' is required.","code":1,"errorCode":"invalid_request","error":{"code":"VALIDATION_ERROR","message":"Field 'licenseKey' is required."}}`, 1, "invalid_request", "Field 'licenseKey' is required."}, + {"without errorCode", http.StatusNotFound, `{"message":"Invalid license key","code":1}`, 1, "", "Invalid license key"}, + {"non-JSON body", http.StatusBadGateway, `Bad Gateway`, -1, "", "API error: Bad Gateway"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + defer done() + + _, err := c.ActivateKey(ActivateKeyParams{ProductID: "p", LicenseKey: "k"}) + apiErr, ok := err.(*ApiError) + if !ok { + t.Fatalf("expected *ApiError, got %T", err) + } + if apiErr.ErrorCode != tc.wantErrorCode || apiErr.Code != tc.wantCode || apiErr.Message != tc.wantMessage { + t.Fatalf("unexpected error: %#v", apiErr) + } + if apiErr.Status == nil || *apiErr.Status != tc.status { + t.Fatalf("status not captured: %#v", apiErr.Status) + } + }) + } +}