Error Catalog
All Platform-Kernel APIs return errors in
RFC 9457 Problem Details
format with Content-Type: application/problem+json.
Base URI: https://platform-kernel.io/problems/
Response structure:
{
"type": "https://platform-kernel.io/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "User usr_01HX0... not found in tenant ten_01HX1...",
"instance": "/api/v1/users/usr_01HX0...",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
For validation errors, an additional errors array is included:
{
"type": "https://platform-kernel.io/problems/validation-error",
"title": "Bad Request",
"status": 400,
"detail": "2 fields failed validation.",
"instance": "/api/v1/users",
"traceId": "...",
"errors": [
{ "field": "email", "message": "Invalid email format", "code": "INVALID_FORMAT" },
{ "field": "name", "message": "Required field", "code": "REQUIRED" }
]
}
Standard Gateway Errors
These error types are produced by
services/gateway/internal/errors/rfc9457.go and may be returned
by any API endpoint.
| HTTP | Error Type URI | Title | Trigger |
|---|---|---|---|
400 | .../problems/validation-error | Bad Request | Request body or query parameter failed OpenAPI schema validation |
401 | .../problems/unauthorized | Unauthorized | JWT is missing, expired, or has an invalid signature |
402 | .../problems/payment-required | Payment Required | Tenant subscription is suspended or terminated |
403 | .../problems/forbidden | Forbidden | Authenticated user lacks the required RBAC permission |
404 | .../problems/not-found | Not Found | Requested resource does not exist or is invisible due to tenant RLS |
405 | .../problems/method-not-allowed | Method Not Allowed | HTTP method is not defined for this endpoint |
429 | .../problems/rate-limit-exceeded | Too Many Requests | Gateway rate limit exceeded (local token bucket or Valkey global cap) |
500 | .../problems/internal-error | Internal Server Error | Unhandled server-side error; check traceId in observability |
502 | .../problems/bad-gateway | Bad Gateway | Upstream gRPC service returned an error or is unreachable |
503 | .../problems/service-unavailable | Service Unavailable | Circuit breaker is OPEN for the target upstream service |
504 | .../problems/gateway-timeout | Gateway Timeout | Upstream gRPC call exceeded deadline |
429 Rate Limit Response Headers
When a 429 is returned, the response includes:
Retry-After: 1
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1745302800
IAM Service Errors
Produced by services/iam/internal/handler/.
Authentication Errors
| HTTP | Error Type URI | Trigger |
|---|---|---|
400 | .../problems/validation-error | Invalid request body, missing required fields |
401 | .../problems/unauthorized | Invalid credentials, expired JWT, invalid TOTP code |
404 | .../problems/not-found | User, role, or provider not found |
409 | .../problems/conflict | Duplicate email on registration |
Tenant Hierarchy Errors
These are produced by tenant_handler.go with full URI constants:
| HTTP | Error Type URI | Trigger |
|---|---|---|
400 | https://platform.kernel/problems/slug-taken | Tenant slug already registered |
400 | https://platform.kernel/problems/rbac-limit-exceeded | RBAC role assignment exceeds plan limit |
403 | https://platform.kernel/problems/insufficient-tier | Operation requires a higher subscription tier |
403 | https://platform.kernel/problems/insufficient-role | Caller role does not permit this delegation |
403 | https://platform.kernel/problems/self-delegation | Caller cannot delegate to themselves |
403 | https://platform.kernel/problems/not-a-descendant | Target tenant is not in caller's subtree |
402 | https://platform.kernel/problems/plan-limit-exceeded | Billing plan limit reached |
410 | https://platform.kernel/problems/tenant-deleted | Tenant has been soft-deleted |
423 | https://platform.kernel/problems/tenant-suspended | Tenant is in suspended billing state |
Remediation
| Error | Action |
|---|---|
unauthorized | Refresh token via POST /auth/refresh |
insufficient-tier | Upgrade subscription via POST /api/v1/billing/subscriptions |
tenant-suspended | Resolve outstanding payment; contact Platform Owner |
not-a-descendant | Verify B2B2B hierarchy with TenantHierarchyService.IsDescendant |
Data Layer Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Missing module/model path, invalid filter operator |
400 | validation-error | Record exceeds 1 MB (DATA_MAX_RECORD_SIZE_BYTES) |
400 | validation-error | JSONB field exceeds 256 KB (DATA_MAX_JSONB_SIZE_BYTES) |
403 | forbidden | RLS: accessing another tenant's data |
404 | not-found | Record ID does not exist or RLS makes it invisible |
422 | validation-error | Relation depth > 2 levels, or > 10 filters in one query |
Money Service Errors
Produced by services/money/internal/handler/handler.go and
services/money/internal/service/service.go.
| HTTP | Code | Trigger |
|---|---|---|
400 | validation_error | userId or currency missing on wallet creation |
400 | invalid_id | Wallet ID is not a valid UUID v7 |
400 | invalid_body | Request JSON is malformed |
400 | missing_tenant | X-Tenant-ID header is absent |
400 | missing_idempotency_key | idempotency_key field missing on write operation |
404 | not-found | Wallet ID not found (repository.ErrWalletNotFound) |
409 | duplicate_wallet | Wallet with same currency + userId already exists |
409 | duplicate_idempotency_key | Request with this idempotency key already processed |
422 | insufficient_funds | Wallet available balance < debit or hold amount (service.ErrInsufficientFunds) |
422 | hold_limit_exceeded | Concurrent holds on wallet exceed 100 (MONEY_MAX_HOLDS_PER_WALLET) |
422 | reversal_window_exceeded | Reversal requested > 365 days after original transaction |
422 | hold_expired | Confirm/Cancel requested on an already-expired hold |
Money Service Remediation
| Error | Action |
|---|---|
insufficient_funds | Check balance via GET /api/v1/wallets/:id/balance before debit |
missing_idempotency_key | Generate idempotencyKey: crypto.randomUUID() and include in request |
duplicate_idempotency_key | Idempotent — the original response was already committed; do not retry |
hold_limit_exceeded | Cancel stale holds via POST /api/v1/wallets/:id/cancel |
File Storage Errors
Produced by services/files/internal/handler/handler.go.
| HTTP | Code / Title | Trigger |
|---|---|---|
400 | invalid-multipart-form | Multipart content-type mismatch or corrupt boundary |
400 | missing-file-field | file field absent in multipart form |
400 | failed-to-read-file | IO error reading uploaded bytes |
400 | missing-tenant-id | X-Tenant-ID header or tenantId query param absent |
400 | missing-file-field | Presign request body does not include a filename |
413 | — | File size exceeds FILES_MAX_IMAGE_SIZE_MB (10 MB) — enforced at proxy |
422 | virus-detected | ClamAV INSTREAM scanner identified malware; file is quarantined |
404 | not-found | File ID does not exist or belongs to another tenant |
422 | staging-timeout | File was not confirmed within 24-hour staging TTL |
422 | max-pending-exceeded | Tenant has 100 in-progress uploads (FILES_STAGING_TTL_HOURS) |
File Storage Remediation
| Error | Action |
|---|---|
virus-detected | File is permanently rejected. Re-upload a clean file. Do not retry the same bytes. |
missing-tenant-id | Add X-Tenant-ID: {tenantId} header to every file request |
staging-timeout | Retry upload — the original staging slot has expired |
Notification Service Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Missing channel, userId, or templateId |
400 | missing-tenant | X-Tenant-ID header absent |
404 | not-found | Template or channel not found |
422 | batch-limit-exceeded | Batch size > 500 notifications |
429 | rate-limit-exceeded | Tenant exceeds 100/min or module exceeds 50/min |
Billing Service Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Invalid plan ID, missing required billing fields |
402 | payment-required | Subscription past due; action requires active subscription |
403 | forbidden | Missing billing.{action} RBAC permission |
404 | not-found | Plan or subscription not found |
409 | conflict | Tenant already has an active subscription to this plan |
422 | invalid-transition | Invalid subscription state transition (e.g., terminated → active) |
423 | tenant-suspended | Tenant in suspension window (days 8–37 after due date) |
Integration Hub Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Missing provider URL, invalid configuration |
404 | not-found | Provider ID not found |
409 | conflict | Provider slug already registered for this tenant |
422 | circuit-open | Circuit breaker is OPEN for this provider (5 failures / 30 s) |
429 | rate-limit-exceeded | Outgoing rate exceeds 100 req/s for this provider |
502 | bad-gateway | Provider returned a non-2xx response after all retries |
503 | service-unavailable | Provider is in circuit-breaker OPEN state |
Audit Service Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Missing action, entityType, or entityId |
400 | missing-tenant | X-Tenant-ID header absent |
404 | not-found | Audit record ID not found |
422 | anonymize-failed | GDPR anonymization conflict (record already anonymized) |
Domain Resolver Errors
| HTTP | Code | Trigger |
|---|---|---|
400 | validation-error | Invalid hostname format |
403 | forbidden | Domain belongs to another tenant |
404 | not-found | Domain ID not found |
422 | domain-limit-exceeded | Tenant has 5 custom domains (DOMAINS_MAX_PER_TENANT) |
422 | dns-verification-failed | CNAME or TXT record not propagated; retry after propagation |
422 | ssl-provision-failed | ACME challenge failed (Let's Encrypt) |
Error Handling Best Practices
Always check type — not status
HTTP status codes are coarse. Use the type URI as the machine-
readable discriminator:
import { KernelApiError } from "@platform/sdk-core";
try {
await kernel.money.debit({ walletId, amount, idempotencyKey });
} catch (err) {
if (err instanceof KernelApiError) {
switch (err.type) {
case "https://platform-kernel.io/problems/insufficient_funds":
showInsufficientFundsUI();
break;
case "https://platform-kernel.io/problems/rate-limit-exceeded":
scheduleRetry(err.retryAfterMs);
break;
default:
reportToSentry(err);
}
}
}
Idempotency on retry
409 duplicate_idempotency_key means the original request was
already committed. Treat this response as success — read the
original result instead of retrying with a new idempotency key.
Trace ID correlation
Every 500-class error includes a traceId. Include this in your
support ticket or internal runbook when escalating. Traces are
available in Jaeger/Tempo via the observability stack.
# Find the trace in logs
kubectl logs -l app=platform-gateway | jq 'select(.trace_id=="4bf92f35...")'
See Also
- Limits Reference — numeric limits that trigger 422/429 errors
- FAQ — common questions
- Glossary — term definitions
- Observability — tracing setup