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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions Types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<html>Bad Gateway</html>`, -1, "", "API error: <html>Bad Gateway</html>"},
}
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)
}
})
}
}
Loading