Handling rejections
When the gateway refuses a write it returns an error that says why and what the record looks like now. The clients turn these into typed exceptions in Python and typed errors in Go. There are five.
| Error | When | What to do |
|---|---|---|
StaleError | You wrote a versioned field with a version that is no longer current | Read the field, decide again, write with the new version |
StalePremiseError | A field in based_on changed since you read it | Read the moved fields, decide again |
HeldError | You claimed a task another agent holds | Skip this record. If it came from a renew, stop working and drop your result |
WeakIdentityError | Your credential is below the identity-strength floor of the class or field (required and presented say which) | Present a stronger credential. Usually a deployment change, not a policy one |
APIError | Anything else: wrong role, missing evidence, bad input | Fix your class or your code. Retrying will not help |
What run() already does
Most of the time you do not catch these. run() reads the record, calls
decide, and writes. If the gateway refuses the write because a field
decide read has moved, run() reads again and calls decide again with
the new fields, up to max_attempts (default 5).
Here is that happening. The plan changes between the read and the write,
so decide runs twice, on two different versions:
decide: planned_action v1 'restart payments-api pods'
decide: planned_action v2 'roll back api deployment'
writtenBoth attempts are in the record’s history, the refused one included:
v1 12:51:25 actions_taken apikey:remediator append APPLIED
evidence: kubectl output
based_on: planned_action@2
-- 12:51:25 actions_taken apikey:remediator append STALE_PREMISE
evidence: kubectl output
based_on: planned_action@1
v2 12:51:25 planned_action apikey:diagnoser put APPLIED
evidence: api logs show config parse error at 14:02:11
v1 12:51:25 planned_action apikey:diagnoser put APPLIED
evidence: api pods in CrashLoopBackOffYour function decided twice, on what was true each time. It never saw the rejection.
When your agent acts as it decides
Re-running decide is safe because decide returns writes rather than
performing them: a second call costs one more decision. If your function
also acts — restarts something, calls an API, spends money — a second
call acts again.
Pass max_attempts=1. Your function runs once and the rejection reaches
you:
try:
run(Agent(decide=decide), client, record=RECORD, max_attempts=1)
except StalePremiseError as e:
for m in e.moved:
print(f"{m.field} moved v{m.your_version} -> v{m.current_version}")Your agent has already acted, so what follows is yours to choose: call
run() again when acting twice is harmless, stop and log when it is not,
or hand it to a person. Either way the attempt is in history with what it
was based on, so the record never claims an action against a plan that
had already moved.
The rest of this page
You need the detail below when you write your own loop, when decide
returns several writes, or when you want to log what happened.
StalePremiseError
from kslate import StalePremiseError
try:
session.append(record, "actions_taken", action, evidence="kubectl output")
except StalePremiseError as e:
for m in e.moved:
print(f"{m.field} moved v{m.your_version} -> v{m.current_version} by {m.current_author}")
print(f" their evidence: {m.current_evidence}")
# read again, decide againe.moved has one entry per field that changed: the version you relied
on, the current version, who wrote it, and what evidence they gave.
e.field and e.target_current_version describe the field you were
writing. The write did not happen.
Do not resubmit the same write with the new version. The point of the error is that the input to your decision changed. Decide again.
StaleError
from kslate import StaleError
try:
session.put(record, "status", "resolved", expected_version=2)
except StaleError as e:
print(f"{e.field} is at v{e.current_version}, you had v{e.your_version}")
print(f"current value {e.current_value!r} by {e.current_author}")
print(e.diff)You tried to write a field someone else changed first. e.current_value
is what is there now, e.current_author wrote it, e.diff is a unified
diff of their value against yours. Read it, decide whether your write
still makes sense, and if so write with expected_version=e.current_version.
If your role may write the field but not read it, current_value,
current_author and current_evidence are None and diff is empty.
You still get current_version.
HeldError
from kslate import HeldError
try:
session.claim(record, "remediation", lease_seconds=300)
except HeldError as e:
print(f"held by {e.owner} until {e.lease_expires_at}")
# skip this recordAnother agent has the task. Move on to the next record. Do not wait for the lease to end and do not poll the claim. The next pass of your loop will try again.
If HeldError comes from a renew (renew=True), your lease ran out and
someone else took the task. Stop. Do not write the result you were about
to write. Another agent owns the task now.
APIError
from kslate import APIError
try:
session.put(record, "planned_action", "x", expected_version=2, evidence="y")
except APIError as e:
print(e.status, e.code, e.body)e.code is the gateway’s error code: SCOPE_DENIED, WEAK_IDENTITY,
EVIDENCE_REQUIRED, NO_ROLE, UNAUTHENTICATED, NOT_FOUND, MALFORMED, and the rest. These
are not races. The same request will fail the same way next time. See
Troubleshooting for the common ones and their
fixes.
In Go
The same five, as error types you test with errors.As:
var stale *kslate.StalePremiseError
var held *kslate.HeldError
var weak *kslate.WeakIdentityError
var api *kslate.APIError
_, err := session.Append(ctx, record, "actions_taken", action, "kubectl output", nil)
switch {
case errors.As(err, &stale):
for _, m := range stale.Moved {
fmt.Printf("%s moved v%d -> v%d by %s\n", m.Field, m.YourVersion, m.CurrentVersion, m.CurrentAuthor)
}
// read again, decide again
case errors.As(err, &held):
// skip this record
case errors.As(err, &weak):
log.Fatalf("%s requires %s, credential is %s", weak.Field, weak.Required, weak.Presented)
case errors.As(err, &api):
log.Fatalf("%s: %v", api.Code, api.Body)
}kslate.StaleError has Field, YourVersion, CurrentVersion,
CurrentValue, CurrentAuthor, CurrentEvidence, CurrentUpdatedAt
and Diff. kslate.WeakIdentityError has Record, Field, Required
and Presented.
Three rules
- Never resubmit unchanged. A rejection means your decision’s input
changed. Decide again with the new input.
kslate verifyflags agents that resubmit the same value after a rejection. - Drop the result when you lose the lease. A
HELDon renew means another agent is now doing this task. Writing your result anyway puts two actions on the record. - One write per decision, if you can. When
decidereturns one write,run()can safely retry the whole cycle. When it returns several, the first may land and the second be rejected, and the loop cannot know whether to redo the first. For a multi-field decision, write them in separate cycles or check the record yourself.