Errors
Branch on the tag. The status is a pure function of it.
A failure is always a JSON body with a _tag and the error's own fields — exactly
the shape the contract declares.
{
"_tag": "Forbidden",
"required": "control",
"held": ["read", "data"]
}Branch on the tag. The status is a summary of something you can already read, and it is a pure function of the tag, so the same error means the same status wherever it is raised.
| Status | Meaning | Examples |
|---|---|---|
| 400 | The request is not acceptable input. A body that does not decode lands here as BadRequest. | MetadataFilterInvalid, MetadataTooLarge, BlobTooLarge, TtlTooLong, RetentionInvalid |
| 401 | No usable credential. | Unauthorized, carrying a reason: unknown, revoked, expired |
| 402 | The spend cap. | SpendLimitExceeded |
| 403 | A credential that is fine, refused for what it asked. | Forbidden, carrying required and held |
| 404 | It is not here. | every *NotFound |
| 409 | It is already there, or it moved. | ProviderNameTaken, ConnectionNameTaken, EnvironmentSlugTaken, VersionConflict |
| 422 | Understood, and refused on its merits. | OverrideNotAllowed, ProviderInUse, SecretInUse, AgentNotReady, PromotionUnresolved, DecisionExpired, ConnectionError, ProviderError |
Three that surprise people
402, not 429, for the spend cap. 429 means "slow down", and every client library
retries it with backoff; a monthly cap is not a rate and retrying it is exactly
wrong. SpendLimitExceeded carries scope, metric, window and resetsAt, so a
caller that wants to wait knows how long. Nothing should retry it automatically.
A cross-environment id is a 404, never a 403. The row was not seen, so the API is not an existence oracle for another tenant's ids.
422 for OverrideNotAllowed, rather than ignoring the field. A caller who asked
for a cheaper model and silently got the expensive one has no way to find out.
In the SDK
Typed failures arrive as PlaneError subclasses, each carrying the wire tag and
the server's fields under data.
import { SpendLimitExceededError, DecisionExpiredError } from "@plane/sdk/client"
try {
await client.sessions.send({ id, message })
} catch (error) {
if (error instanceof SpendLimitExceededError) {
console.warn(`${error.scope} out of ${error.metric} until ${error.resetsAt}`)
return
}
throw error
}A tag this version of the SDK does not know still arrives as a PlaneError with that
tag, so a contract addition never breaks an older client. Transport and validation
problems are PlaneTransportError and PlaneValidationError.