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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- `KeyMintApiError.ErrorCode`: the stable string error code the API now sends with every error (for example `license_invalid_or_forbidden`, `rate_limited`). Branch on it instead of `Message`. `null` when the response has none; existing properties are unchanged.

### Fixed

- `KeyMintApiError.Error` now deserialises when the API sends `error` as a plain string (for example the 405 "Method not allowed" and the IP-allowlist 403) as well as an object. A string is kept in `Error.Message`; before, these responses failed to parse and returned a generic `HTTP error: ...` with `Code = -1`.
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,40 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|-------------------------|--------------------------------------------------|
| `VerifyWebhookSignature`| Verifies the signature of a webhook request payload. |

## Error Handling

SDK methods don't throw for API errors. They return a `KeyMintResult<T>`, and when `IsSuccess` is false, `Error` is a `KeyMintApiError` with `Message`, `Code`, `ErrorCode` and `Status`. 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.

```csharp
var result = await client.ActivateKey(new ActivateKeyParams {
ProductId = productId,
LicenseKey = licenseKey,
HostId = hostId
});

if (result.IsSuccess) {
Console.WriteLine($"Activated: {result.Data!.Message}");
} else {
switch (result.Error!.ErrorCode) {
case "license_invalid_or_forbidden":
// Rejected: ask the user to re-enter the key or contact support.
break;
case "activation_limit_reached":
// Ask the user to deactivate another device.
break;
case "rate_limited":
case "service_unavailable":
// No answer yet: keep the last known state and retry later.
break;
default:
Console.WriteLine($"Keymint error {result.Error.ErrorCode ?? result.Error.Code.ToString()} (HTTP {result.Error.Status}): {result.Error.Message}");
break;
}
}
```

`ErrorCode` is `null` when the response has none, for example a non-JSON body from a proxy or a network failure, where `Code` is `-1`. `Code` and `Error` (the legacy `error` field) are unchanged.

## Idempotency

All mutating SDK methods support idempotency keys to safely retry requests in case of network drops. Pass a `RequestOptions` instance as the optional second argument:
Expand Down
2 changes: 2 additions & 0 deletions Services/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ public class KeyMintApiError
public string Message { get; set; } = "Keymint API request failed"; // Descriptive error message
[JsonPropertyName("code")]
public int Code { get; set; } // API numeric error code
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; set; } // Stable string error code (e.g. "license_not_found"); branch on this, not on Message
[JsonPropertyName("status")]
public int? Status { get; set; } // HTTP status code, optional
[JsonPropertyName("error")]
Expand Down
57 changes: 57 additions & 0 deletions Tests/ApiContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,63 @@ public void Api_error_without_error_field_deserializes(string json)
Assert.Null(error.Error);
}

[Fact]
public async Task Api_errors_expose_error_code()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent(
"{\"message\":\"Invalid license key or insufficient permissions\",\"code\":1,\"errorCode\":\"license_invalid_or_forbidden\"}",
Encoding.UTF8,
"application/json")
});
var sdk = CreateSdk(handler);

var result = await sdk.ActivateKey(new ActivateKeyParams { ProductId = "product_123", LicenseKey = "key_123" });

Assert.False(result.IsSuccess);
Assert.Equal("license_invalid_or_forbidden", result.Error!.ErrorCode);
Assert.Equal("Invalid license key or insufficient permissions", result.Error.Message);
Assert.Equal(1, result.Error.Code);
Assert.Equal(401, result.Error.Status);
}

[Fact]
public async Task Api_errors_without_error_code_leave_it_null()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
{
Content = new StringContent("{\"message\":\"Too many requests\",\"code\":3}", Encoding.UTF8, "application/json")
});
var sdk = CreateSdk(handler);

var result = await sdk.ActivateKey(new ActivateKeyParams { ProductId = "product_123", LicenseKey = "key_123" });

Assert.False(result.IsSuccess);
Assert.Null(result.Error!.ErrorCode);
Assert.Equal("Too many requests", result.Error.Message);
Assert.Equal(3, result.Error.Code);
Assert.Equal(429, result.Error.Status);
}

[Fact]
public async Task Non_json_errors_have_no_error_code()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = new StringContent("<html>Bad Gateway</html>", Encoding.UTF8, "text/html")
});
var sdk = CreateSdk(handler);

var result = await sdk.ActivateKey(new ActivateKeyParams { ProductId = "product_123", LicenseKey = "key_123" });

Assert.False(result.IsSuccess);
Assert.Null(result.Error!.ErrorCode);
Assert.Equal(-1, result.Error.Code);
Assert.Equal(502, result.Error.Status);
Assert.StartsWith("HTTP error: BadGateway", result.Error.Message);
}

private static KeyMintSDK CreateSdk(HttpMessageHandler handler)
{
return new KeyMintSDK("admin_test", new HttpClient(handler)
Expand Down
Loading