Errors and retries
Every failure comes back in one shape, and the field to branch on is not the one you would expect. This page covers that shape, what idempotency actually guarantees when you retry, and the single case where the API cannot tell you which of two things went wrong.
One shape, every endpoint
RFC 9457 problem details, served as application/problem+json. Four members are
always present.
{
"type": "https://developer.plantactic.com/errors/not-found",
"title": "Not found",
"status": 404,
"detail": "No dataset 9f2c4b1e-…, or it has no active version.",
"instance": "/v1/datasets/9f2c4b1e-…/metrics"
} -
typeis what you branch on. A stable URI identifying the kind of problem. It does not change for a given failure, which is exactly what the other fields do not promise. -
titleis a short summary, stable for a giventype— and often just the HTTP reason phrase, so it carries less than it looks like it does. -
detailis prose for a person. We will keep improving it. Log it, show it, quote it to support; never match on it. Some details interpolate a collection whose order is not fixed, so two runs of the same service can word the same failure differently. -
instanceis the request path — and it is optional. Endpoints set it; the authentication filter and the idempotency filter do not, because those reject a request before it reaches an endpoint. Read it as nullable rather than guaranteed.
Some problems carry extension members beyond those four. RFC 9457 permits it, and ingest validation is where you will meet it.
Ingest validation
A bad upload returns every problem in the payload, not the first one, and writes nothing at all.
{
"type": "https://developer.plantactic.com/errors/ingest-validation-failed",
"title": "Unprocessable Entity",
"status": 422,
"detail": "3 row(s) were rejected. Nothing was written.",
"instance": "/v1/datasets/9f2c4b1e-…/versions",
"errors": [
{
"row": 1,
"field": "endDate",
"code": "not_a_date",
"message": "'2025-13-01' is not an ISO-8601 date (expected YYYY-MM-DD)."
},
{
"row": 2,
"field": "customer",
"code": "required",
"message": "customer is required."
}
]
}
Each entry names the row as you count it in your source, the field, a stable code,
and a message written for a person. Branch on code. A row with several
bad cells produces several entries, so the array can be longer than the number of rows.
Nothing is written, so there is no partial state to reconcile — fix the file and send it again. A validator that stopped at the first error would turn a hundred bad rows into a hundred round trips, which is the whole reason it does not.
Validation is not the same as usefulness. A row can pass every check here and still contribute nothing to the metrics you care about — see the four fields that gate a contract. That failure produces no error at all, which is why it is worth reading about before you need to.
Idempotency and retries
Idempotency-Key is required on every mutating call: creating a dataset, creating a
version, requesting an upload URL, requesting an export. Omit it and the call is refused before
anything happens.
{
"type": "https://developer.plantactic.com/errors/idempotency-key-required",
"title": "Bad Request",
"status": 400,
"detail": "Idempotency-Key is required on this call. Send a unique value
— a UUID is fine — so a retry cannot repeat the operation."
} Within a 24-hour window, a key you have used before behaves in one of two ways.
- Same key, same body — you get the original response back, including its status code. Nothing runs twice. This is what makes a retry after a timeout or a dropped connection safe.
- Same key, different body — a
422. Not a second operation, and not a silent overwrite of the first.
{
"type": "https://developer.plantactic.com/errors/idempotency-key-reused",
"title": "Unprocessable Entity",
"status": 422,
"detail": "That Idempotency-Key was already used for a different request
body. Use a new key for a new request."
} That second case is the one that surprises people, and it surprises them at the worst moment: you send a file, get a validation error, fix a row, and send again with the same key. That is a different body, so it is a 422 about your key rather than a fresh attempt.
So derive the key from the operation, not the attempt. "This dataset, this period, this revision of the file" is a good key. A fresh UUID per retry defeats the mechanism entirely — every retry becomes a new operation, which is what idempotency exists to prevent.
What is safe to retry
The short version: retry 5xx and network failures, with the same key. Do not retry
a 4xx without changing something, because nothing about the request will have
changed either.
- Timeout, dropped connection,
502,503— retry with the sameIdempotency-Key. Either the first attempt landed and you get its response, or it did not and you get a fresh one. There is no third outcome. -
400,401,403,422— do not retry as-is. Fix the request. If you fix the body, use a new key. -
404— usually do not retry, with one exception covered below. -
409— you have hit a uniqueness rule, most often anexternalRefthat already exists on your account. Retrying is pointless; the existing resource is the answer.
There is no 429. Nothing here rate-limits, and nothing rejects a
request for being expensive — see Compute units. Do not
build a backoff for a status this API does not send; build one for the network, which is real.
The one ambiguous 404
Asking for metrics or the catalog on a dataset you cannot get an answer for returns a
404 that covers two different situations: the dataset does not exist,
and it exists but has no active version yet. The body is the same for both.
The right reaction differs completely. The first means stop and check your id. The second means
an upload is still building and you should poll. Until they separate, resolve the dataset first
— GET /v1/datasets/{id} answers whether it exists, and its
activeVersionId answers whether anything is servable.
Worth knowing rather than working around: this is a gap in the response, and it is recorded as one.