toolnexus.client/provider-error
Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §8 · clojure/src/toolnexus/client.cljc
(provider-error status body retry-after);; => an ex-info whose DATA is;; {:toolnexus/error :provider;; :status 402 ; the HTTP status, verbatim;; :body "…" ; redacted, UNCAPPED — the cap is message-only;; :retry-after "3"} ; the RAW Retry-After header, verbatim; omitted when absent;; and whose MESSAGE is "LLM 402: <redacted body, capped at 200 chars>"
;; catch it with ex-data, the idiomatic Clojure way — there is no exception CLASS to catch(try (client/run llm prompt {:toolkit tk}) (catch Throwable e (when (= :provider (:toolnexus/error (ex-data e))) (:status (ex-data e)))))A non-2xx response from the model endpoint raises a typed provider error carrying the status
code, a redacted+capped body, and the raw Retry-After header — never a bare unstructured exception.
provider-error is the constructor run calls internally the moment an HTTP attempt exhausts its
retry budget on a terminal status; you do not normally call it yourself, but its shape is exactly
what your catch sees.
There is no ProviderError record or exception class in this port — unlike js/python/golang/
java/csharp, which each define a named type (LlmHttpError, ProviderError, ProviderException).
Clojure represents the same failure as a plain ex-info (clojure.lang.ExceptionInfo) tagged with
a namespaced key, {:toolnexus/error :provider ...}, in its ex-data. That is an idiomatic choice,
not a lesser one: ex-info + ex-data is how this language spells “a typed, structured
failure” — a host branches with (= :provider (:toolnexus/error (ex-data e))) instead of an
instanceof/isinstance check, and the same tagging convention already carries :timeout (see
timeout-error) and every other client-thrown failure in this
namespace.
When to use it
Section titled “When to use it”- Catching a failed LLM call without regexing a raw exception message. Before this shape
existed, a host’s only signal was the message string itself, account identifiers and all
(issues #91/#92, ADR 0027) — now
ex-datagives you:status,:bodyand:retry-afteras data. - Deciding whether to surface a status to a user (e.g. show “rate limited, retrying” on 429) without parsing prose.
- Building your own retry/alerting layer above
:retries— read:retry-afteroff a caught error the same way:on-error’sinfomap exposes it per attempt, if you need the value after the budget is already exhausted and the call has thrown.
Why this and not the alternative
Section titled “Why this and not the alternative”Keeping this as an ex-info rather than inventing a defrecord and a matching deftype predicate
also keeps the port to one .cljc file compiled for two hosts (JVM + cljgo): ex-info/ex-data
are core Clojure on both, whereas a custom exception class is exactly the kind of host-specific
surface this port avoids.
Examples
Section titled “Examples”1. The smallest useful call — catch and branch on status
Section titled “1. The smallest useful call — catch and branch on status”(require '[toolnexus.client :as client] '[toolnexus.tool :as tool])
(println)
(def llm (client/create-client {:base-url "http://mock.local" :style "openai" :model "gpt-4o-mini" :http-client (fn [_url _headers _body] {:status 402 :headers {} :body "{\"error\":\"insufficient_quota\"}"})}))
(def toolkit (tool/toolkit []))
(def caught (try (client/run llm "Summarise the incident" {:toolkit toolkit}) (catch Throwable e (let [d (ex-data e)] (if (= :provider (:toolnexus/error d)) (do (println "provider failed with status" (:status d)) (:status d)) (throw e))))))
(assert (= 402 caught))(println "OK")2. The realistic case — surface a rate-limit to the caller with the retry hint
Section titled “2. The realistic case — surface a rate-limit to the caller with the retry hint”(require '[toolnexus.client :as client] '[toolnexus.tool :as tool])
(println)
(defn run-and-report [llm prompt toolkit] (try {:status "done" :result (client/run llm prompt {:toolkit toolkit})} (catch Throwable e (let [d (ex-data e)] (if (= :provider (:toolnexus/error d)) {:status "provider-error" :http-status (:status d) :retry-after (:retry-after d) ; the raw header, or nil if none was sent :detail (:body d)} ; already redacted, safe to log or show (throw e))))))
(def llm (client/create-client {:base-url "http://mock.local" :style "openai" :model "gpt-4o-mini" :retries 0 :http-client (fn [_url _headers _body] {:status 429 :headers {"retry-after" "2"} :body "{\"error\":\"rate limited\"}"})}))
(def toolkit (tool/toolkit []))(def report (run-and-report llm "hi" toolkit))
(assert (= "provider-error" (:status report)))(assert (= 429 (:http-status report)))(assert (= "2" (:retry-after report)))(println "OK"):body here is safe to log or return to a caller as-is — account identifiers (user_id,
account_id, org_id, organization) are already replaced with «redacted», and a 401/403 body
is blanked to "" entirely rather than redacted, per the credentials guarantee (ADR 0027, D5).
3. The full surface — every field, and the message-vs-data split
Section titled “3. The full surface — every field, and the message-vs-data split”(require '[toolnexus.client :as client] '[toolnexus.tool :as tool] '[clojure.string :as str])
(println)
(def llm (client/create-client {:base-url "http://mock.local" :style "openai" :model "gpt-4o-mini" :retries 0 :http-client (fn [_url _headers _body] {:status 402 :headers {"retry-after" "3"} :body "{\"account_id\":\"acct_9\",\"user_id\":\"u-42\",\"message\":\"over quota\"}"})}))
(def toolkit (tool/toolkit []))
(def surface (try (client/run llm "hi" {:toolkit toolkit}) (catch Throwable e (let [d (ex-data e)] (when (= :provider (:toolnexus/error d)) {:status (:status d) ; e.g. 402 — verbatim HTTP status :body (:body d) ; redacted, UNCAPPED — the full body, minus identifiers :retry-after (:retry-after d) ; the raw header verbatim, present only if one was sent :message (ex-message e) ; "LLM 402: <redacted body>", CAPPED at 200 chars + "…" :message-len (count (ex-message e))})))))
(assert (= 402 (:status surface)))(assert (= "3" (:retry-after surface)))(assert (str/includes? (:body surface) "«redacted»"))(assert (not (str/includes? (:body surface) "acct_9")))(assert (str/starts-with? (:message surface) "LLM 402:"))(println "OK")The cap applies to the exception message only — (:body (ex-data e)) is the whole redacted
body, however long, because a host that opted into a typed error asked for the full diagnostic, not
a truncated one (addendum A5). Only the human-readable message string is bounded.
See also
Section titled “See also”toolnexus.client/create-client— Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly — the resilience policy that decides whether a failing call ever reachesprovider-errorat all.toolnexus.client/run— Send a prompt, let the loop call tools until the model stops, get a RunResult.toolnexus.client/create-client— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.toolnexus.client/run— Keep a transcript across turns so the model remembers what it already did.