Skip to content

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.

  • 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-data gives you :status, :body and :retry-after as 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-after off a caught error the same way :on-error’s info map exposes it per attempt, if you need the value after the budget is already exhausted and the call has thrown.

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.

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.

  • 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 reaches provider-error at 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.