Run Error Surface from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page parses every pasted response and audits it against the skills' failure modes (the RFC 9457 envelope and its consistency, the media type, the machine-readable code, field-level detail, the retry signal, the request ID, leaked internals, wrong statuses), chooses the taxonomy by rank, classes every status as retryable or not, lists the retry-unsafe endpoints and works out the backoff, then hands all of it to the run as facts. The run writes the catalog or the retry contract; the page then holds it to its own facts (errreply.js): the taxonomy it chose, every observed code mapped, one status per code, retryable following the status, payloads that agree with their entry; exactly the retry-unsafe endpoints, your backoff numbers, a replay window above the floor, two distinct conflict codes, the retry allowlist, and never exactly-once.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"error-surface"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, api, responses, facts) is missing. The page always sends all five of task, api, responses, carry, facts; carry is an empty string for catalog. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - the errors, then the retries:
catalog- the redesigned error surface: the taxonomy, one catalog entry per error with every observed code mapped, rewritten messages, two to four RFC 9457 payloads, the stability contract and the rollout.retry- the idempotency and retry contract for the same endpoints: which take a key, the key and its scope, both conflicts, the replay window, the claim, the failure policy, the client retries on your backoff numbers, and the contract for integrators.
A missing or unknown task is answered as catalog. Both lanes send the same five fields. The facts are the page's audit, one response per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the catalog's codes into carry for the retry lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | catalog or retry. If it is missing or neither, do catalog and set "task": "catalog". |
api | yes | the API's name, what it does, the paradigm, the consumers, retrofit or greenfield, the taxonomy scope, the ship date, payments-style failures, the documentation base URL, what clients already branch on, the key store, external calls, the longest retry chain and the SDK languages; "not given" means not given. |
responses | yes | the error responses as pasted, each starting with its request line. |
carry | send it (empty for catalog) | retry task only: the catalog's codes, when the catalog task ran first; may be empty. |
facts | yes | the page's audit, one line per response, the status classes, and for each task the decisions and numbers the page settled. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skills, @samber/api-error-design and @samber/api-idempotency-retry - the only standard the run applies:
Error design
- Pick the correct HTTP status first; the body explains the status, never replaces it. 400 is malformed, 422 is syntactically valid but semantically invalid; 401 is not authenticated, 403 is authenticated but not allowed. Never a 200 carrying an error.
- One envelope everywhere: RFC 9457 problem details (type, title, status, detail, instance; application/problem+json) with extension members for the machine-readable identity - code (or domain and reason), errors for field-level detail, retryable, documentation_url, request_id.
- The taxonomy: a code/subcode catalog by default; a flat catalog for a small surface or a near ship date; domain/reason (Google AIP-193) for REST plus gRPC, gRPC only, or a taxonomy several services share. A payments-style domain may need a layered type, code and decline code. Every code a client can see is in the catalog.
- Messages are human-readable, actionable and consistent: name the field, the value received and the constraint; plain, non-blaming, corrective language. 4xx when the caller must change the request, 5xx when they should retry later or the provider must fix a bug. Say per code whether the message is safe to show end users.
- Retry signals: retryable on 408, 429, 502, 503, 504 and sometimes 500; never on other 4xx. Retry-After on every 429 and 503; a retryable boolean in the body even when the header is there; a 500 always states retryable explicitly.
- A request ID header on every response, echoed as request_id in the body.
- Codes are contract (Hyrum's Law): code is stable and safe to branch on, message and detail text are not contractual. Evolve by addition only; never rename, repurpose or delete a shipped code - map every old code to its new identity, and retire codes only on a version boundary.
Idempotency and retry
- POST and PATCH are not idempotent by method; every state-changing endpoint either accepts an idempotency key or is documented as unsafe to retry. Verify PUT and DELETE rather than trusting the method.
- The client mints the key (event-derived by default, then request-scoped, then a random UUID reused across retries; never a fresh value per attempt); the server scopes it per caller (account plus key).
- The same key with a different payload is rejected (409), never replayed; an in-flight duplicate gets its own distinct conflict code (409, or 202 with a status URL). Decide whether a stored failure replays verbatim or may be re-attempted.
- Two retention windows: the replay window (about 24 hours, never shorter than the longest retry chain) and a longer reaper threshold. After expiry a reused key is a new request.
- Claim the key atomically: a unique constraint on (account, key) by default, conditional writes on a store without transactions, a row lock in a SERIALIZABLE transaction when requests call non-rollbackable external systems. A bare cache lock without fencing tokens is not a claim.
- Clients retry an allowlist only - 429 honouring Retry-After, 502, 503, 504, connection timeouts and resets, 500 cautiously - and never other 4xx. Backoff always carries jitter (full jitter by default, decorrelated when time-to-success matters more than load). Bound both attempts and the total duration under one overall deadline; add a fleet-wide retry budget.
- Never write "exactly-once"; write what is true - retries are safe within the documented key scope and window.
House rules
- Use only the input. Never invent an endpoint, a status an endpoint returns, a customer, a traffic figure, a vendor's number or a claim about the system; an old code or message you name appears in the responses or in what clients already branch on.
- The page's audit is settled: never call a flagged problem fine or a clean response broken. Where it flags a wrong status (a 200 carrying an error, 401 and 403 swapped, field validation on a 400), the new entry uses the right status.
- URLs: type and documentation_url start with the documentation base the facts give; no other URL.
- Write in the language of the input; keep the JSON keys and the codes in English.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
catalog
Redesign the error surface.
summary- one sentence, at most 40 words: what is broken for callers today and the first fix.taxonomy-shape(exactly the facts' taxonomy:flat,code-subcodeordomain-reason) andwhy(one sentence).catalog- one object per error a caller can see, grouped by status:code(lower_snake for flat and code/subcode, UPPER_SNAKE reason for domain/reason),subcode(for code/subcode where a category has several reasons, else ""),domain(for domain/reason, else ""),status(one status per code),title,retryable(true or false, following the status classes; a 409 for a request still in flight may be true, since waiting and resending the same key succeeds),user_safe(true when the message may be shown to an end user verbatim),message(the rewritten detail: names the field, the value and the constraint where one applies, and the fix),replaces(the old codes and message strings this entry takes over, exactly as they appear in the input; [] for a new error). Every observed code is in some entry's replaces.examples- two to four complete RFC 9457 payloads for entries of different statuses:code(the entry's code) andpayload(type, title, status, detail, instance, then the extension members: code, or domain and reason; subcode when used; errors with field, code and detail for validation; retryable; retry_after in seconds for a 429 or 503; documentation_url; request_id).stability- two to four strings: what is contract and what is not, and how codes evolve and retire.rollout- three to six strings, in order: how to ship the new surface without breaking clients that branch on the old codes.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "catalog",
"summary": "one sentence",
"taxonomy": { "shape": "code-subcode", "why": "one sentence" },
"catalog": [ { "code": "validation_failed", "subcode": "field_out_of_range", "domain": "", "status": 422, "title": "Validation failed", "retryable": false, "user_safe": false, "message": "amount must be between 50 and 99999999; received 20.", "replaces": [ "param_invalid" ] } ],
"examples": [ { "code": "validation_failed", "payload": { "type": "https://docs.example.com/errors/validation_failed", "title": "Validation failed", "status": 422, "detail": "amount must be between 50 and 99999999; received 20.", "instance": "/v1/charges", "code": "validation_failed", "errors": [ { "field": "/amount", "code": "field_out_of_range", "detail": "must be at least 50" } ], "retryable": false, "documentation_url": "https://docs.example.com/errors/validation_failed", "request_id": "req_..." } } ],
"stability": [ "one string" ],
"rollout": [ "a step" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in errreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every URL starts with the documentation base; the taxonomy is the page's; no duplicate entries; each status a 4xx or 5xx, and one status per code; retryable true on 408, 429, 502, 503 and 504, false on other 4xx, stated on 500; user_safe stated; a message that names a field, value or fix; codes in the shape's case, a domain only for domain/reason, no subcodes in a flat catalog; every replaced code or message present in the input, and every observed code replaced; two to four payloads of different statuses, each with type, title, status, detail, the entry's status, code and retryable, a request_id, a documentation_url, and retry_after on a 429 or 503; at least two stability statements and three rollout steps. Notes that do not flag: a validation payload without errors, and a stability contract that never says what is contract.
retry
Write the idempotency and retry contract for the same endpoints.
summary- one sentence, at most 40 words.unsafe- one object per retry-unsafe endpoint the facts list, in that order:endpoint(exactly as the facts write it),key(requiredoroptional),why(one sentence).verify- one object per PUT or DELETE endpoint the facts list:endpoint,why(what to check before trusting its idempotency); [] when none.key-header(Idempotency-Key unless the input uses another),derivation(event-derived,request-scopedoruuid-reused),example(one example key for this API),scope(one sentence),max_length(a number of characters).conflicts-mismatchandinflight, each withstatusandcode(two different codes; when the catalog is carried, its codes as it writes them, with /subcode where it has one).ttl-replay_hours(at least the facts' floor),reaper_hours(longer than the replay window),why.claim-mechanism(unique-constraint,conditional-writeorrow-lock),why(one sentence that fits the key store).failures-policy(replayorre-attempt; one policy for the whole contract),why(naming the endpoints where it matters most).client-retry_on(statuses as numbers, plus "timeout" and "connection reset"),never_retry(statuses as numbers),jitter(fullordecorrelated),base_ms,cap_ms,max_retriesanddeadline_ms(copied from the facts),budget(one sentence on the fleet-wide retry budget).docs- the integrator-facing contract as one plain-text string: which endpoints take the key, the header, the derivation, the scope, the replay window in hours, both conflicts with their codes, the failure policy and a worked retry schedule from the facts' numbers.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "retry",
"summary": "one sentence",
"unsafe": [ { "endpoint": "POST /v1/charges", "key": "required", "why": "one sentence" } ],
"verify": [ ],
"key": { "header": "Idempotency-Key", "derivation": "event-derived", "example": "charge-order-1042", "scope": "one sentence", "max_length": 255 },
"conflicts": { "mismatch": { "status": 409, "code": "idempotency_key_reused" }, "inflight": { "status": 409, "code": "request_in_progress" } },
"ttl": { "replay_hours": 24, "reaper_hours": 72, "why": "one sentence" },
"claim": { "mechanism": "unique-constraint", "why": "one sentence" },
"failures": { "policy": "replay", "why": "one sentence" },
"client": { "retry_on": [ 429, 502, 503, 504, "timeout", "connection reset" ], "never_retry": [ 400, 401, 403, 404, 409, 422 ], "jitter": "full", "base_ms": 500, "cap_ms": 30000, "max_retries": 4, "deadline_ms": 60000, "budget": "one sentence" },
"docs": "the contract as plain text",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; every URL starts with the documentation base; exactly the retry-unsafe endpoints, each with key required or optional; exactly the PUT and DELETE endpoints to verify; a header, a known derivation, a max length, and an example key that is not freshly generated; two different conflict codes, a 409 or 422 mismatch and a 409, 202 or 425 in-flight; a replay window at least the floor and a longer reaper; a claim mechanism that fits the key store, with fencing named when only a cache is available; replay or re-attempt for stored failures; retries only on 408, 429, 500, 502, 503 and 504 and always on 429, 502, 503 and 504, nothing retryable in never_retry; full or decorrelated jitter; the backoff numbers copied; a retry budget; a contract that names the header and the replay window in hours; and no exactly-once claim. A non-rollbackable external call without a row lock is a note that does not flag.
Worked examples
Three runs from the page's free samples (invented APIs, endpoints and ids): the catalog for a payments API in production, the retry contract for the same API carrying that catalog, and the catalog for a data platform on REST and gRPC. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the responses, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
catalog - A payments API in production
The page read it as REST only, callers cannot self-serve (4 fail, 19 warn, 7 info); envelopes: {error: {code, message}}; {error}; codes seen: param_invalid, card_declined, resource_missing, refund_exists, rate_limit; taxonomy: code-subcode.
Input (facts is 2487 characters; the first 1399 are shown):
{
"task": "catalog",
"api": "API: Ledgerline Payments API\nWhat it does: Card payments, refunds and payouts for online merchants; integrators call it from their own servers.\nParadigm: REST only\nConsumers: Third-party integrators\nMode: Retrofit - these are production responses\nTaxonomy scope: One team, one service\nShip date: No hard date\nPayments-style layered failures: yes\nDocumentation base URL: https://docs.ledgerline.example/errors\nWhat clients already branch on: Integrators branch on card_declined and its decline_code, and on resource_missing.\nKey store: Relational DB (unique constraints, transactions)\nCalls a non-rollbackable external system mid-request: yes\nLongest retry chain: 72 h\nSDK languages: Node, Python",
"responses": "POST /v1/charges\nIdempotency-Key: order-1042-charge\n\nHTTP/1.1 400 Bad Request\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lw9\n\n{\"error\": {\"code\": \"param_invalid\", \"message\": \"Invalid input\"}}\n\nPOST /v1/charges\nIdempotency-Key: order-1043-charge\n\nHTTP/1.1 402 Payment Required\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx1\n\n{\"error\": {\"code\": \"card_declined\", \"message\": \"Your card was declined.\", \"decline_code\": \"insufficient_funds\"}}\n\nGET /v1/charges/ch_8Hd2kQ1\n\nHTTP/1.1 404 Not Found\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx4\n\n{\"error\": {\"code\": \"resource_missing\", \"message\": \"No such charge: ch_8Hd2kQ1\"}}\n\nPOST /v1/refunds\n\nHTTP/1.1 409 Conflict\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx8\n\n{\"error\": {\"code\": \"refund_exists\", \"message\": \"REFUND_ALREADY_EXISTS\"}}\n\nPOST /v1/charges\n\nHTTP/1.1 429 Too Many Requests\nContent-Type: application/json\nX-Request-Id: req_7Hk2Ly0\n\n{\"error\": {\"code\": \"rate_limit\", \"message\": \"Too many requests\"}}\n\nPOST /v1/payouts\n\nHTTP/1.1 500 Internal Server Error\nContent-Type: application/json\n\n{\"error\": \"Internal Server Error\", \"trace\": \"SQLSTATE[40001]: Serialization failure in payouts_repo.py:212\"}",
"carry": "",
"facts": "The page's audit (4 fail, 19 warn, 7 info), one line per pasted response - endpoint | status | envelope | code | message | findings:\n- POST /v1/charges | 400 | {error: {code, message}} | code param_invalid | message \"Invalid input\" | warn:not-9457, warn:generic-message, warn:no-field, warn:rid-not-echoed, info:no-doc\n- POST /v1/charges | 402 | {error: {code, message}} | code card_declined | message \"Your card was declined.\" | warn:not-9457, warn:rid-not-echoed, info:no-doc\n- GET /v1/charges/{id} | 404 | {error: {code, message}} | code resource_missing | message \"No such charge: ch_8Hd2kQ1\" | warn:not-9457, warn:rid-not-echoed, info:no-doc\n- POST /v1/refunds | 409 | {error: {code, message}} | code refund_exists | message \"REFUND_ALREADY_EXISTS\" | warn:not-9457, warn:code-as-message, warn:rid-not-echoed, info:no-doc\n- POST /v1/charges | 429 | {error: {code, message}} | code rate_limit | message \"Too many requests\" | warn:not-9457, fail:no-retry-after, warn:rid-not-echoed, info:no-doc\n- POST /v1/payouts | 500 | {error} | code none | message \"Internal Server Error\" | warn:not-9457, fail:no-code, warn:generic-message, warn:bare-5xx, warn:no-request-id, info:no-doc, fail:leak\n- across responses: fail:mixed-envelopes - 2 envelope shapes across the error responses ({error: {code, message}}; {error}) - one envelope everywhere is binary for the caller\n- across responses: warn:coverage -\n\n[... 1088 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "catalog",
"summary": "Callers get two envelopes, a 429 with no Retry-After, a 500 that leaks a SQL trace and no request ID, and messages like Invalid input; first move every error to one problem+json envelope with stable codes.",
"taxonomy": {
"shape": "code-subcode",
"why": "One REST service with no hard date fits a code per category pinned to one status, with subcodes for the reasons - and the layered card decline keeps decline_code beside the subcode."
},
"catalog": [
{
"code": "invalid_request",
"subcode": "parameter_invalid",
"domain": "",
"status": 400,
"title": "Invalid request parameter",
"retryable": false,
"user_safe": false,
"message": "The parameter {param} received {value}, which does not meet {constraint}; correct {param} and send the request again. The errors array lists every failing field with its own reason.",
"replaces": [
"param_invalid",
"Invalid input"
]
},
{
"code": "card_declined",
"subcode": "insufficient_funds",
"domain": "",
"status": 402,
"title": "Card declined",
"retryable": false,
"user_safe": true,
"message": "The card was declined because the account has insufficient funds. Use a different card or add funds, then try the payment again.",
"replaces": [
"card_declined",
"Your card was declined.",
"insufficient_funds"
]
},
{
"code": "resource_missing",
"subcode": "charge_not_found",
"domain": "",
"status": 404,
"title": "Charge not found",
"retryable": false,
"user_safe": false,
"message": "No charge with id {id} exists for this account; check the id was copied in full and that the request uses the API key of the account that created the charge.",
"replaces": [
"resource_missing",
"No such charge: ch_8Hd2kQ1"
]
},
{
"code": "conflict",
"subcode": "refund_already_exists",
"domain": "",
"status": 409,
"title": "Refund already exists",
"retryable": false,
"user_safe": false,
"message": "Charge {charge} already has refund {refund}; retrieve that refund instead of creating another one.",
"replaces": [
"refund_exists",
"REFUND_ALREADY_EXISTS"
]
},
{
"code": "conflict",
"subcode": "idempotency_key_reused",
"domain": "",
"status": 409,
"title": "Idempotency key reused with a different request",
"retryable": false,
"user_safe": false,
"message": "Idempotency-Key {key} was already used with a different request body on {endpoint}; send the original body with this key, or mint a new key for the new operation.",
"replaces": []
},
{
"code": "conflict",
"subcode": "idempotency_request_in_progress",
"domain": "",
"status": 409,
"title": "Request with this idempotency key still in progress",
"retryable": false,
"user_safe": false,
"message": "A request with Idempotency-Key {key} is still being processed; do not mint a new key - wait for it to finish, then resend the same request with the same key to receive its stored result.",
"replaces": []
},
{
"code": "rate_limited",
"subcode": "",
"domain": "",
"status": 429,
"title": "Too many requests",
"retryable": true,
"user_safe": false,
"message": "This account sent more requests than its rate limit allows; wait the number of seconds in Retry-After (also retry_after in the body), then resend with the same Idempotency-Key.",
"replaces": [
"rate_limit",
"Too many requests"
]
},
{
"code": "internal_error",
"subcode": "",
"domain": "",
"status": 500,
"title": "Internal error",
"retryable": true,
"user_safe": false,
"message": "The request failed on our side and its outcome may be unknown; retry with backoff using the same Idempotency-Key so it cannot run twice, and if it keeps failing contact support with the request_id.",
"replaces": [
"Internal Server Error"
]
}
],
"examples": [
{
"code": "invalid_request",
"payload": {
"type": "https://docs.ledgerline.example/errors/invalid_request",
"title": "Invalid request parameter",
"status": 400,
"detail": "The parameter amount received -5, which does not meet the minimum of 1; correct amount and send the request again.",
"instance": "/v1/charges",
"code": "invalid_request",
"subcode": "parameter_invalid",
"errors": [
{
"field": "/amount",
"code": "parameter_invalid",
"detail": "must be at least 1; received -5"
}
],
"retryable": false,
"documentation_url": "https://docs.ledgerline.example/errors/invalid_request",
"request_id": "req_7Hk2Lw9"
}
},
{
"code": "card_declined",
"payload": {
"type": "https://docs.ledgerline.example/errors/card_declined",
"title": "Card declined",
"status": 402,
"detail": "The card was declined because the account has insufficient funds. Use a different card or add funds, then try the payment again.",
"instance": "/v1/charges",
"code": "card_declined",
"subcode": "insufficient_funds",
"decline_code": "insufficient_funds",
"retryable": false,
"documentation_url": "https://docs.ledgerline.example/errors/card_declined",
"request_id": "req_7Hk2Lx1"
}
},
{
"code": "rate_limited",
"payload": {
"type": "https://docs.ledgerline.example/errors/rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "This account sent more requests than its rate limit allows; wait 30 seconds, then resend with the same Idempotency-Key.",
"instance": "/v1/charges",
"code": "rate_limited",
"retryable": true,
"retry_after": 30,
"documentation_url": "https://docs.ledgerline.example/errors/rate_limited",
"request_id": "req_7Hk2Ly0"
}
},
{
"code": "internal_error",
"payload": {
"type": "https://docs.ledgerline.example/errors/internal_error",
"title": "Internal error",
"status": 500,
"detail": "The payout request failed on our side and its outcome may be unknown; retry with the same Idempotency-Key, and contact support with this request_id if it keeps failing.",
"instance": "/v1/payouts",
"code": "internal_error",
"retryable": true,
"documentation_url": "https://docs.ledgerline.example/errors/internal_error",
"request_id": "req_7Hk2Lz3"
}
}
],
"stability": [
"Contract: code, subcode, decline_code, status, retryable and the type URL are stable and safe to branch on; title, detail and the message wording are not contractual and may change at any time.",
"card_declined and resource_missing keep their exact names because integrators already branch on them; every other old code maps one-to-one to a new code and subcode and is never renamed, repurposed or reused.",
"The catalog grows by addition only - a new decline reason is a new subcode under card_declined, never a new meaning for an existing one.",
"Old codes retire only on an API version boundary, after the deprecation notice and the side-by-side period."
],
"rollout": [
"Fix what breaks nothing first: send X-Request-Id on every response including the payouts 500, echo it as request_id in the body, remove the SQL trace from 500 bodies, and add Retry-After to every 429.",
"On the current API version keep the error object and its code exactly as today, and add the new members beside it - new_code, subcode, retryable and documentation_url - so code still reads param_invalid, rate_limit or refund_exists while [... 30 more characters cut from this display]",
"Ship RFC 9457 problem details as application/problem+json on the next API version, with the old code carried as a legacy_code extension member and decline_code kept on card declines.",
"Release Node and Python SDKs that branch on code and subcode, fall back to the old-to-new mapping table when only the old code is present, and expose retryable and request_id on every error.",
"Publish the mapping table and a deprecation date at https://docs.ledgerline.example/errors, then drop legacy_code and the old codes only on the following version boundary - never inside a version."
],
"caveats": [
"Only insufficient_funds was observed; every other decline reason the card path returns needs its own subcode before launch.",
"The example field values and the 30-second retry_after are illustrative, not observed limits.",
"The in-flight conflict is marked not retryable because 409 is; clients resend it once with the same key after the first request completes, outside the generic retry loop."
]
}
Cut from this display only (the saved reply and the check use the full text): rollout[1] (267 characters, 237 shown).
What step 5 prints for it: invalid_request 400 | card_declined 402 | resource_missing 404 | conflict 409 | conflict 409 | conflict 409 | rate_limited 429 | internal_error 500
The page's check: kept - nothing to flag
retry - A payments API in production
The input carries the saved catalog's codes (R.laneForm fills carry from the catalog reply), as the page's handoff does. Retry-unsafe endpoints: POST /v1/charges, POST /v1/refunds, POST /v1/payouts.
Input (facts is 2642 characters; the first 1399 are shown):
{
"task": "retry",
"api": "API: Ledgerline Payments API\nWhat it does: Card payments, refunds and payouts for online merchants; integrators call it from their own servers.\nParadigm: REST only\nConsumers: Third-party integrators\nMode: Retrofit - these are production responses\nTaxonomy scope: One team, one service\nShip date: No hard date\nPayments-style layered failures: yes\nDocumentation base URL: https://docs.ledgerline.example/errors\nWhat clients already branch on: Integrators branch on card_declined and its decline_code, and on resource_missing.\nKey store: Relational DB (unique constraints, transactions)\nCalls a non-rollbackable external system mid-request: yes\nLongest retry chain: 72 h\nSDK languages: Node, Python",
"responses": "POST /v1/charges\nIdempotency-Key: order-1042-charge\n\nHTTP/1.1 400 Bad Request\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lw9\n\n{\"error\": {\"code\": \"param_invalid\", \"message\": \"Invalid input\"}}\n\nPOST /v1/charges\nIdempotency-Key: order-1043-charge\n\nHTTP/1.1 402 Payment Required\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx1\n\n{\"error\": {\"code\": \"card_declined\", \"message\": \"Your card was declined.\", \"decline_code\": \"insufficient_funds\"}}\n\nGET /v1/charges/ch_8Hd2kQ1\n\nHTTP/1.1 404 Not Found\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx4\n\n{\"error\": {\"code\": \"resource_missing\", \"message\": \"No such charge: ch_8Hd2kQ1\"}}\n\nPOST /v1/refunds\n\nHTTP/1.1 409 Conflict\nContent-Type: application/json\nX-Request-Id: req_7Hk2Lx8\n\n{\"error\": {\"code\": \"refund_exists\", \"message\": \"REFUND_ALREADY_EXISTS\"}}\n\nPOST /v1/charges\n\nHTTP/1.1 429 Too Many Requests\nContent-Type: application/json\nX-Request-Id: req_7Hk2Ly0\n\n{\"error\": {\"code\": \"rate_limit\", \"message\": \"Too many requests\"}}\n\nPOST /v1/payouts\n\nHTTP/1.1 500 Internal Server Error\nContent-Type: application/json\n\n{\"error\": \"Internal Server Error\", \"trace\": \"SQLSTATE[40001]: Serialization failure in payouts_repo.py:212\"}",
"carry": "invalid_request/parameter_invalid | 400 | not retryable | Invalid request parameter\ncard_declined/insufficient_funds | 402 | not retryable | Card declined\nresource_missing/charge_not_found | 404 | not retryable | Charge not found\nconflict/refund_already_exists | 409 | not retryable | Refund already exists\nconflict/idempotency_key_reused | 409 | not retryable | Idempotency key reused with a different request\nconflict/idempotency_request_in_progress | 409 | not retryable | Request with this idempotency key still in progress\nrate_limited | 429 | retryable | Too many requests\ninternal_error | 500 | retryable | Internal error",
"facts": "The page's audit (4 fail, 19 warn, 7 info), one line per pasted response - endpoint | status | envelope | code | message | findings:\n- POST /v1/charges | 400 | {error: {code, message}} | code param_invalid | message \"Invalid input\" | warn:not-9457, warn:generic-message, warn:no-field, warn:rid-not-echoed, info:no-doc\n- POST /v1/charges | 402 | {error: {code, message}} | code card_declined | message \"Your card was declined.\" | warn:not-9457, warn:rid-not-echoed, info:no-doc\n- GET /v1/charges/{id} | 404 | {error: {code, message}} | code resource_missing | message \"No such charge: ch_8Hd2kQ1\" | warn:not-9457, warn:rid-not-echoed, info:no-doc\n- POST /v1/refunds | 409 | {error: {code, message}} | code refund_exists | message \"REFUND_ALREADY_EXISTS\" | warn:not-9457, warn:code-as-message, warn:rid-not-echoed, info:no-doc\n- POST /v1/charges | 429 | {error: {code, message}} | code rate_limit | message \"Too many requests\" | warn:not-9457, fail:no-retry-after, warn:rid-not-echoed, info:no-doc\n- POST /v1/payouts | 500 | {error} | code none | message \"Internal Server Error\" | warn:not-9457, fail:no-code, warn:generic-message, warn:bare-5xx, warn:no-request-id, info:no-doc, fail:leak\n- across responses: fail:mixed-envelopes - 2 envelope shapes across the error responses ({error: {code, message}}; {error}) - one envelope everywhere is binary for the caller\n- across responses: warn:coverage -\n\n[... 1243 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "retry",
"summary": "Only charges send an Idempotency-Key today; require it on charges, refunds and payouts, hold keys 72 h to cover the longest retry chain, and claim each key under a row lock because money leaves through a non-rollbackable call.",
"unsafe": [
{
"endpoint": "POST /v1/charges",
"key": "required",
"why": "A retried charge without a key can capture the card twice, and integrators already send keys like order-1042-charge here."
},
{
"endpoint": "POST /v1/refunds",
"key": "required",
"why": "A refund retried after a timeout must return the first refund, not fail with refund_already_exists or refund twice."
},
{
"endpoint": "POST /v1/payouts",
"key": "required",
"why": "A payout that returned a 500 may already have moved money, so a retry is only safe when the key ties it to the first attempt."
}
],
"verify": [],
"key": {
"header": "Idempotency-Key",
"derivation": "event-derived",
"example": "order-1042-charge",
"scope": "A key is unique per account: the same key from two accounts is two requests, and one account reusing a key on another endpoint is a payload mismatch.",
"max_length": 255
},
"conflicts": {
"mismatch": {
"status": 409,
"code": "idempotency_key_reused"
},
"inflight": {
"status": 409,
"code": "idempotency_request_in_progress"
}
},
"ttl": {
"replay_hours": 72,
"reaper_hours": 168,
"why": "The longest retry chain is 72 h, so a 24 h window would let a late retry charge or pay out again; records are reaped after 7 days so a stuck claim can still be investigated."
},
"claim": {
"mechanism": "row-lock",
"why": "The relational store can insert the (account, key) row under a unique constraint and hold it with a row lock in a SERIALIZABLE transaction, recording a recovery point after each call to the non-rollbackable payment system so a retry [... 32 more characters cut from this display]"
},
"failures": {
"policy": "replay",
"why": "A final outcome such as a decline or a validation error is stored and replayed verbatim for the same key, while a request that died mid-flight is not stored as a failure - the retry resumes from its last recovery point."
},
"client": {
"retry_on": [
429,
500,
502,
503,
504,
"timeout",
"connection reset"
],
"never_retry": [
400,
401,
402,
403,
404,
409,
422
],
"jitter": "full",
"base_ms": 500,
"cap_ms": 20000,
"max_retries": 4,
"deadline_ms": 60000,
"budget": "The Node and Python SDKs let retries add at most 10 percent on top of first attempts per client over a rolling minute, and fail fast once that budget is spent so an outage is not multiplied by the fleet."
},
"docs": "Idempotency and retries - Ledgerline Payments API\n\nWhich requests take a key: POST /v1/charges, POST /v1/refunds and POST /v1/payouts require an Idempotency-Key request header. A request to these endpoints without one is rejected rather [... 2017 more characters cut from this display]",
"caveats": [
"Making the key required on refunds and payouts breaks integrators who call them without one today - ship it on a version boundary.",
"The 10 percent retry budget and the 168 h reaper threshold are recommended starting values, not measured ones."
]
}
Cut from this display only (the saved reply and the check use the full text): claim.why (264 characters, 232 shown); docs (2253 characters, 236 shown).
What step 5 prints for it: POST /v1/charges - key required | POST /v1/refunds - key required | POST /v1/payouts - key required
The page's check: kept - nothing to flag
catalog - A data platform on REST and gRPC
The page read it as REST and gRPC, fixes recommended (0 fail, 6 warn, 2 info); envelopes: RFC 9457 problem details; codes seen: INVALID_QUERY, QUOTA_EXCEEDED, BACKEND_UNAVAILABLE, INTERNAL; taxonomy: domain-reason.
Input (responses is 1446 characters; the first 1399 are shown; facts is 1733 characters; the first 1394 are shown):
{
"task": "catalog",
"api": "API: Brindlemoor Data Platform\nWhat it does: Datasets, query jobs and exports for data teams; most callers are agents and scheduled pipelines, over REST and gRPC.\nParadigm: REST and gRPC\nConsumers: Machine and agent callers\nMode: Greenfield - these are planned responses\nTaxonomy scope: Several services will share it for years\nShip date: No hard date\nPayments-style layered failures: no\nDocumentation base URL: https://dev.brindlemoor.example/errors\nWhat clients already branch on: Pipelines already match on QUOTA_EXCEEDED.\nKey store: Only a cache (e.g. Redis)\nCalls a non-rollbackable external system mid-request: no\nLongest retry chain: 168 h\nSDK languages: Python, Go",
"responses": "POST /v1/datasets/{id}:query\n\nHTTP/1.1 400 Bad Request\nContent-Type: application/problem+json\nX-Request-Id: nw-a91f\n\n{\"type\": \"https://dev.brindlemoor.example/errors/INVALID_QUERY\", \"title\": \"Invalid query\", \"status\": 400, \"detail\": \"Unknown column 'region_id' in WHERE clause.\", \"domain\": \"query.brindlemoor.example\", \"reason\": \"INVALID_QUERY\", \"retryable\": false}\n\nPOST /v1/exports\n\nHTTP/1.1 429 Too Many Requests\nContent-Type: application/problem+json\nRetry-After: 30\nX-Request-Id: nw-a922\n\n{\"type\": \"https://dev.brindlemoor.example/errors/QUOTA_EXCEEDED\", \"title\": \"Quota exceeded\", \"status\": 429, \"detail\": \"Export quota of 20 per hour reached for this project.\", \"domain\": \"exports.brindlemoor.example\", \"reason\": \"QUOTA_EXCEEDED\", \"retryable\": true, \"retry_after\": 30, \"request_id\": \"nw-a922\"}\n\nGET /v1/jobs/{id}\n\nHTTP/1.1 503 Service Unavailable\nContent-Type: application/problem+json\nRetry-After: 5\nX-Request-Id: nw-a930\n\n{\"type\": \"https://dev.brindlemoor.example/errors/BACKEND_UNAVAILABLE\", \"title\": \"Backend unavailable\", \"status\": 503, \"detail\": \"The query engine is restarting.\", \"domain\": \"jobs.brindlemoor.example\", \"reason\": \"BACKEND_UNAVAILABLE\", \"retryable\": true}\n\nPOST /v1/exports\n\nHTTP/1.1 500 Internal Server Error\nContent-Type: application/problem+json\nX-Request-Id: nw-a944\n\n{\"type\": \"about:blank\", \"title\": \"Internal Server Error\", \"status\": 500, \"domain\": \"exports.brindlemoor.example\",\n\n[... 47 more characters cut from this display ...]",
"carry": "",
"facts": "The page's audit (0 fail, 6 warn, 2 info), one line per pasted response - endpoint | status | envelope | code | message | findings:\n- POST /v1/datasets/{id}:query | 400 | RFC 9457 problem details | code INVALID_QUERY | message \"Unknown column 'region_id' in WHERE clause.\" | warn:no-field, warn:rid-not-echoed\n- POST /v1/exports | 429 | RFC 9457 problem details | code QUOTA_EXCEEDED | message \"Export quota of 20 per hour reached for this project.\" | clean\n- GET /v1/jobs/{id} | 503 | RFC 9457 problem details | code BACKEND_UNAVAILABLE | message \"The query engine is restarting.\" | warn:rid-not-echoed\n- POST /v1/exports | 500 | RFC 9457 problem details | code INTERNAL | message \"Internal Server Error\" | warn:generic-message, warn:bare-5xx, info:no-doc\n- across responses: info:no-key - 2 state-changing endpoints pasted without an Idempotency-Key request header: POST /v1/datasets/{id}:query, POST /v1/exports\n- across responses: warn:chain - the longest retry chain is 168 h - a 24 h replay window would let a late retry duplicate the effect\nStatus classes: 400 non-retryable, 429 retryable, 500 ambiguous, 503 retryable.\nTaxonomy: domain-reason (domain/reason (Google AIP-193 ErrorInfo)) - the family spans REST and gRPC, so the identity must be independent of HTTP status.\nCodes observed in the responses (each is contract): INVALID_QUERY, QUOTA_EXCEEDED, BACKEND_UNAVAILABLE, INTERNAL.\n\n[... 339 more characters cut from this display ...]"
}
Reply:
{
"task": "catalog",
"summary": "The envelope is already RFC 9457 with domain and reason, but the 500 is a bare about:blank with no message or retry stance and two responses drop request_id; first make every planned response carry the full extension set.",
"taxonomy": {
"shape": "domain-reason",
"why": "The same errors cross REST and gRPC and several services will share them for years, so identity must be a domain plus an UPPER_SNAKE reason that does not depend on the HTTP status."
},
"catalog": [
{
"code": "INVALID_QUERY",
"subcode": "",
"domain": "query.brindlemoor.example",
"status": 400,
"title": "Invalid query",
"retryable": false,
"user_safe": true,
"message": "The column {column} in the WHERE clause does not exist in dataset {dataset_id}; use a column from the dataset's schema and submit the query again.",
"replaces": [
"INVALID_QUERY",
"Unknown column 'region_id' in WHERE clause."
]
},
{
"code": "IDEMPOTENCY_KEY_REUSED",
"subcode": "",
"domain": "query.brindlemoor.example",
"status": 409,
"title": "Idempotency key reused with a different query",
"retryable": false,
"user_safe": false,
"message": "Idempotency-Key {key} was already used for a different query on dataset {dataset_id}; resend the original body with this key, or use a new key for the new query.",
"replaces": []
},
{
"code": "IDEMPOTENCY_REQUEST_IN_PROGRESS",
"subcode": "",
"domain": "query.brindlemoor.example",
"status": 409,
"title": "Query with this idempotency key still in progress",
"retryable": false,
"user_safe": false,
"message": "A query with Idempotency-Key {key} is still being accepted; do not mint a new key - wait for it to finish, then resend the same request with the same key to get its stored result.",
"replaces": []
},
{
"code": "IDEMPOTENCY_KEY_REUSED",
"subcode": "",
"domain": "exports.brindlemoor.example",
"status": 409,
"title": "Idempotency key reused with a different export",
"retryable": false,
"user_safe": false,
"message": "Idempotency-Key {key} was already used for a different export request; resend the original body with this key, or use a new key for the new export.",
"replaces": []
},
{
"code": "IDEMPOTENCY_REQUEST_IN_PROGRESS",
"subcode": "",
"domain": "exports.brindlemoor.example",
"status": 409,
"title": "Export with this idempotency key still in progress",
"retryable": false,
"user_safe": false,
"message": "An export with Idempotency-Key {key} is still being accepted; do not mint a new key - wait for it to finish, then resend the same request with the same key to get its stored result.",
"replaces": []
},
{
"code": "QUOTA_EXCEEDED",
"subcode": "",
"domain": "exports.brindlemoor.example",
"status": 429,
"title": "Quota exceeded",
"retryable": true,
"user_safe": false,
"message": "Project {project} reached its export quota of {limit} per hour; wait the number of seconds in Retry-After (also retry_after in the body) before the next export, and spread scheduled exports across the hour.",
"replaces": [
"QUOTA_EXCEEDED"
]
},
{
"code": "INTERNAL",
"subcode": "",
"domain": "exports.brindlemoor.example",
"status": 500,
"title": "Internal error",
"retryable": true,
"user_safe": false,
"message": "The export request failed on our side and its outcome may be unknown; retry with backoff using the same Idempotency-Key so it cannot create a second export, and report the request_id if it keeps failing.",
"replaces": [
"INTERNAL",
"Internal Server Error"
]
},
{
"code": "BACKEND_UNAVAILABLE",
"subcode": "",
"domain": "jobs.brindlemoor.example",
"status": 503,
"title": "Backend unavailable",
"retryable": true,
"user_safe": false,
"message": "The query engine is restarting and cannot report job {job_id} right now; wait the number of seconds in Retry-After (also retry_after in the body), then read the job again.",
"replaces": [
"BACKEND_UNAVAILABLE",
"The query engine is restarting."
]
}
],
"examples": [
{
"code": "INVALID_QUERY",
"payload": {
"type": "https://dev.brindlemoor.example/errors/INVALID_QUERY",
"title": "Invalid query",
"status": 400,
"detail": "The column region_id in the WHERE clause does not exist in this dataset; use a column from the dataset's schema and submit the query again.",
"instance": "/v1/datasets/{id}:query",
"domain": "query.brindlemoor.example",
"reason": "INVALID_QUERY",
"errors": [
{
"field": "/query",
"code": "UNKNOWN_COLUMN",
"detail": "region_id is not a column of this dataset (WHERE clause)"
}
],
"retryable": false,
"documentation_url": "https://dev.brindlemoor.example/errors/INVALID_QUERY",
"request_id": "nw-a91f"
}
},
{
"code": "QUOTA_EXCEEDED",
"payload": {
"type": "https://dev.brindlemoor.example/errors/QUOTA_EXCEEDED",
"title": "Quota exceeded",
"status": 429,
"detail": "Export quota of 20 per hour reached for this project; wait 30 seconds before the next export, and spread scheduled exports across the hour.",
"instance": "/v1/exports",
"domain": "exports.brindlemoor.example",
"reason": "QUOTA_EXCEEDED",
"metadata": {
"quota_limit": "20",
"quota_window": "1h"
},
"retryable": true,
"retry_after": 30,
"documentation_url": "https://dev.brindlemoor.example/errors/QUOTA_EXCEEDED",
"request_id": "nw-a922"
}
},
{
"code": "BACKEND_UNAVAILABLE",
"payload": {
"type": "https://dev.brindlemoor.example/errors/BACKEND_UNAVAILABLE",
"title": "Backend unavailable",
"status": 503,
"detail": "The query engine is restarting and cannot report this job right now; wait 5 seconds, then read the job again.",
"instance": "/v1/jobs/{id}",
"domain": "jobs.brindlemoor.example",
"reason": "BACKEND_UNAVAILABLE",
"retryable": true,
"retry_after": 5,
"documentation_url": "https://dev.brindlemoor.example/errors/BACKEND_UNAVAILABLE",
"request_id": "nw-a930"
}
},
{
"code": "INTERNAL",
"payload": {
"type": "https://dev.brindlemoor.example/errors/INTERNAL",
"title": "Internal error",
"status": 500,
"detail": "The export request failed on our side and its outcome may be unknown; retry with the same Idempotency-Key, and report this request_id if it keeps failing.",
"instance": "/v1/exports",
"domain": "exports.brindlemoor.example",
"reason": "INTERNAL",
"retryable": true,
"documentation_url": "https://dev.brindlemoor.example/errors/INTERNAL",
"request_id": "nw-a944"
}
}
],
"stability": [
"Contract: the pair of domain and reason, the status, retryable and the type URL are stable and safe to branch on; title, detail and metadata values are not contractual and may change.",
"QUOTA_EXCEEDED in exports.brindlemoor.example stays exactly as it is because pipelines already match on it; a reason is unique within its domain and is never renamed, repurposed or deleted.",
"Services add reasons by registering them in the shared catalog first; an unregistered reason never ships, and reasons retire only on a major version boundary of the API."
],
"rollout": [
"Before launch, complete every planned response: echo X-Request-Id as request_id on the 400 and the 503, add errors with field-level detail to INVALID_QUERY, and give the 500 a real type URL, a detail and retryable instead of about:blank.",
"Publish the shared catalog of domains and reasons at https://dev.brindlemoor.example/errors and make each service's error builder refuse a domain and reason pair that is not registered.",
"Emit the same domain and reason as gRPC ErrorInfo with the matching canonical status - INVALID_ARGUMENT, ABORTED, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE - so REST and gRPC callers branch on one identity.",
"Ship the Python and Go SDKs with typed errors that expose domain, reason, retryable, retry_after and request_id, and retry only on retryable true.",
"Keep QUOTA_EXCEEDED byte-for-byte through launch; any later change is a new reason added beside it, with the old one retired only on a major version."
],
"caveats": [
"The errors field pointer /query and the metadata keys are illustrative; use the real request body field names.",
"INTERNAL is marked retryable because the retry carries an idempotency key; without one, a retry of POST /v1/exports could create a second export."
]
}
What step 5 prints for it: INVALID_QUERY 400 | IDEMPOTENCY_KEY_REUSED 409 | IDEMPOTENCY_REQUEST_IN_PROGRESS 409 | IDEMPOTENCY_KEY_REUSED 409 | IDEMPOTENCY_REQUEST_IN_PROGRESS 409 | QUOTA_EXCEEDED 429 | INTERNAL 500 | BACKEND_UNAVAILABLE 503
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./errkit.js"); require("./errreply.js")), then:
const K = ErrKit, R = ErrReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme API", paradigm: "rest", storage: "relational",
responses: "POST /v1/orders\n\nHTTP/1.1 400 Bad Request\nContent-Type: application/json\n\n{\"message\": \"Invalid input\"}\n\n" +
"POST /v1/orders\n\nHTTP/1.1 429 Too Many Requests\n\n{\"code\": \"rate_limited\", \"message\": \"Slow down\"}" }));
// f.audits (each response: shape, code, findings), f.global, f.tax, f.eps (unsafe endpoints), f.sched (the backoff)
// K.problemSchema(f) - the OpenAPI ProblemDetails component; K.auditCsv(f) - the audit
const b = K.buildInput(f, "catalog"); // or "retry" (R.laneForm(form, "retry", catalog) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "catalog");
// R.check(reply, b) // {status: kept or flagged, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://error-surface.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://error-surface.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// error-surface.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://error-surface.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://error-surface.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// Error Surface.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class Error Surface {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://error-surface.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://error-surface.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://error-surface.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://error-surface.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"error-surface"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "error-surface"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "error-surface" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "error-surface"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "error-surface"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "error-surface" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "error-surface"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "error-surface" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "catalog", "api": "API: Ledgerline Payments API\nParadigm: REST only\n...", "responses": "POST /v1/charges\n\nHTTP/1.1 400 Bad Request\n...", "carry": "", "facts": "The page's audit (4 fail, 19 warn, ...):\n- POST /v1/charges | 400 | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "catalog", "api": "API: Ledgerline Payments API\nParadigm: REST only\n...", "responses": "POST /v1/charges\n\nHTTP/1.1 400 Bad Request\n...", "carry": "", "facts": "The page's audit (4 fail, 19 warn, ...):\n- POST /v1/charges | 400 | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "catalog" or "retry", api, responses, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses error-surface:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A catalog reply carries summary, taxonomy, catalog, examples, stability, rollout and caveats; a retry reply carries summary, unsafe, verify, key, conflicts, ttl, claim, failures, client, docs and caveats. The code below prints the gist of either: each code and its status, or each retry-unsafe endpoint.
KEY="error-surface:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "retry" then (.unsafe[] | "\(.endpoint) - key \(.key)")
else (.catalog[] | "\(.code) \(.status)") end' reply.json
import hashlib, time
key = "error-surface:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "retry":
for u in reply["unsafe"]:
print(u["endpoint"], "- key", u["key"])
else:
for e in reply["catalog"]:
print(e["code"], e["status"])
import { createHash } from "node:crypto";
const key = `error-surface:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "retry") for (const u of reply.unsafe) console.log(u.endpoint, "- key", u.key);
else for (const e of reply.catalog) console.log(e.code, e.status);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "error-surface:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
if reply["task"] == "retry" {
us, _ := reply["unsafe"].([]any)
for _, u := range us {
uu := u.(map[string]any)
fmt.Println(uu["endpoint"], "- key", uu["key"])
}
} else {
es, _ := reply["catalog"].([]any)
for _, e := range es {
ee := e.(map[string]any)
fmt.Println(ee["code"], ee["status"])
}
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "error-surface:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("retry")) {
for (JsonNode u : reply.path("unsafe")) System.out.println(u.path("endpoint").asText() + " - key " + u.path("key").asText());
} else {
for (JsonNode e : reply.path("catalog")) System.out.println(e.path("code").asText() + " " + e.path("status").asInt());
}
require "digest"
key = "error-surface:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "retry"
reply["unsafe"].each { |u| puts "#{u["endpoint"]} - key #{u["key"]}" }
else
reply["catalog"].each { |e| puts "#{e["code"]} #{e["status"]}" }
end
$key = "error-surface:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "retry") { foreach ($reply["unsafe"] as $u) echo $u["endpoint"], " - key ", $u["key"], "\n"; }
else { foreach ($reply["catalog"] as $e) echo $e["code"], " ", $e["status"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"error-surface:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "retry") { foreach (var u in reply["unsafe"]!.AsArray()) Console.WriteLine($"{u!["endpoint"]} - key {u["key"]}"); }
else { foreach (var e in reply["catalog"]!.AsArray()) Console.WriteLine($"{e!["code"]} {e["status"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: error-surface:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "catalog", "api": "API: Ledgerline Payments API\nParadigm: REST only\n...", "responses": "POST /v1/charges\n\nHTTP/1.1 400 Bad Request\n...", "carry": "", "facts": "The page's audit (4 fail, 19 warn, ...):\n- POST /v1/charges | 400 | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "error-surface:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `error-surface:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "error-surface:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "error-surface:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "error-surface:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: error-surface:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"error-surface:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);