Skip to main content
Every error the library raises derives from CharterError, so a host application can catch the whole surface with one except. The runtime decides nothing about what happens next: it raises a typed exception and hands the decision back.

CharterError

The base class.
Optional[str]
A documentation slug, such as "auth/oauth-flow", or None. Set on errors whose fix is a procedure rather than an edit: registering an OAuth app, choosing scopes, standing up a callback route. Errors whose message already states the fix carry none, and neither does ToolValidationError, whose message is written to be handed back to a model that cannot follow a link.
Optional[str]
The slug rendered against the documentation site, or None. Appended to the message by str(exc), and readable on its own for a host that formats errors itself. Appending .md to it returns the page as markdown, which is what an agent debugging a pack should fetch.
Errors carry a slug rather than a URL so that moving a page is one edit rather than a change at every raise site, and so tests/test_error_docs.py can resolve every slug the library can print against the pages that ship beside it. A link inside an exception reaches someone who is already stuck; a dead one is worse than none.

DeclarationError

A schema, marker or pack was declared in a shape the runtime cannot use: a field with no Path(), Query() or Body() marker, a Pagination naming half a style, an Envelope that could never detect a failure, a Format naming a transform nobody registered. It names whose mistake this is, not when it surfaced. Most are raised while a tool is built or a declaration validated. Some cannot be detected until a request is assembled, and come out of ainvoke instead — an unmarked field, a body that will not form-encode, a pack whose host was never configured. Either way the fix is in the declaration and the reader is its author, which is why these carry a docs link where an error written for a model does not.
Guarding only the declaration is not enough. A tool with an unmarked field builds without complaint and raises on the first call, so a host catching ToolValidationError, CredentialError and APIError around ainvoke and nothing else will let this one escape. except CharterError catches it.
Also a ValueError, deliberately. Several are raised inside a pydantic validator, which converts ValueError and nothing else, and a host that already catches ValueError around a declaration keeps working unchanged. except CharterError catches them too.

CredentialError

str
What went wrong.
Optional[str]
Whose credentials failed — "google" — or None when the failure is not attributable to one provider.
Optional[int]
The HTTP status that triggered it, when it came from a response — 401 by default, or whatever credential_statuses declared. None when the credentials were missing locally or a refresh failed.
bool
Whether the API refused the token itself, so its provider should drop it. True for a credential status and an envelope’s credential_errors; False for an envelope’s permission_errors, where the token works and may not do this, and for a failure raised before any request. Defaults to whether there is a status_code.
bool
Whether the grant itself is gone, so only the user authorizing again brings it back. True when a token endpoint answered invalid_grant or a code its OAuth2Server declares in dead_grant_errors, for the repeats of that error during the cool-down, and when a GrantLoader finds no grant stored. False for everything else, including an access token the API refused, which the next call replaces by refreshing.
str(exc) prefixes the provider in brackets when there is one. Raised by: a provider with nothing to hand back (EnvTokenProvider on an unset variable, an unconfigured pack, a SubjectProvider with no subject set); a token endpoint refusing a refresh or an exchange; a response whose status is in credential_statuses; an envelope failure whose code is in credential_errors; and the constructor guards on StaticTokenProvider, CallbackProvider, OAuth2Client and OAuth2Flow. The library never re-authenticates on its own. This is the signal to run whatever flow you own and retry — see the OAuth flow.

InsufficientScope

The token works, and what it was granted does not cover this call. One error for every provider, however the provider says it: the standard WWW-Authenticate: Bearer error="insufficient_scope" challenge on any response, or whatever the factory’s ScopeRefusal declares. A CredentialError, so an existing except CredentialError keeps catching it; token_refused is always False, because a refresh returns the same grant.
list[str]
The scopes to add so this call runs, narrowest first. What the provider named, read against the tool’s declaration so that of several it would accept, the narrowest the tool lists comes first; the tool’s own declaration when the provider names nothing.
tuple[str, ...]
Every scope the provider said would let the call through, any one of which will do, in its order. Empty when it named none, which is how to tell that needed came from the declaration.
Optional[Kind]
The kind of credential the scopes belong to, when not the pack’s default: stripe.RESTRICTED_KEY for a restricted key’s permission, slack.USER for a bot token on a user-only method.
status_code is the response’s status, or 403 when the refusal came inside a 200. See Permissions for what each provider sends.

ToolValidationError

str
Written to be handed straight back to a model as the tool result: it names the offending fields and how to fix them.
Optional[str]
Which tool’s input failed, when known.
list[dict[str, Any]]
The structured per-field errors. [] when none were supplied — never None, so iterating is always safe.
Raised before the network, by ainvoke validating arguments against llm_schema(). A call that fails here cost no request, no rate-limit budget and no money. See handling it for the retry loop this is designed for, and the auto-corrections applied before it raises.

ContextError

str
The tool that was called.
str
The FromContext field whose value was missing or did not fit.
str
The key the host was expected to pass it under in context.
Raised by ainvoke before any request, and before any rule reads the value, when the host’s context lacks a value a required FromContext field needs or holds one of the wrong type. The mistake is the application’s, and the message says so: the adapters hand a CharterError back to the model as the tool’s result, and a model told only that a field was missing would try to supply it. A rule that refuses because of the host’s value raises ToolValidationError instead, since that one is the model’s to act on.

TransformError

str
What failed.
Optional[str]
The transform name, as written in the Format marker.
Optional[str]
The field it was applied to, when known. Appended to str(exc).
Raised by apply_transform when the name is not registered (the message lists what is), when the value does not validate as the transform’s semantic type, or when the transform function itself raises. Also before the network.

APIError

int
The status the API returned. 200 when a declared envelope found the failure inside a success — that really was the status.
str
An excerpt of the response body, truncated to 500 characters so it stays safe to pass into a context window.
Optional[str]
The request URL, when known.
Optional[int]
Seconds the server asked the caller to wait, parsed from Retry-After — usual on 429, common on 503. Only the delta-seconds form is parsed; the HTTP-date form is legal but rare in JSON APIs, and guessing at clock skew is worse than reporting nothing.Two fallbacks, for APIs that answer the same question elsewhere. When there is no Retry-After, X-RateLimit-Reset is read — but only when X-RateLimit-Remaining is 0, so an ordinary response carrying its quota headers is not reported as a wait. GitHub needs this: it sends no Retry-After and answers 403 rather than 429. And an Envelope with a retry_after resolver fills this in for an API that refuses inside a 200, which is how a cost-budgeted API like Shopify reports a throttle.
str(exc) joins the status, URL, message, retry hint and body excerpt with em dashes. Charter never retries, and retry_after is why: it refuses to discard the server’s own answer to “when?”, and leaves the policy to you.

NetworkError

The request got no usable answer: the connection could not be opened, dropped, or timed out. Raised wherever Charter sends a request — the tool’s own call and every redirect hop, the token request an OAuth2Client makes before it, an OAuth2Flow code exchange, and a revocation. The httpx exception is __cause__. Nothing was learned about the call itself, so the message says that for the model reading it: the arguments were not the problem and no change to the call fixes it. It also says whether the request can have taken effect, because that is what decides whether repeating it is safe:
A failed token request names the authorization server and what it was doing — while getting an access token — so a model is not told the API was down when it was never contacted.
bool
False when the connection never opened (a connect timeout, a refused connection, an exhausted pool), so the request cannot have been received. True otherwise — a read timeout can follow a request the server received and acted on, which is how a repeated send_mail sends twice.
Optional[str]
The host that could not be reached.
Optional[str]
The request URL without its query string, which can carry identifiers.
Charter never retries; may_have_taken_effect is what a host needs to decide whether to. In observability the call’s outcome is transport_error and its error_type is the httpx class it wraps.

Catching the surface

Order matters in two places: InsufficientScope is a CredentialError, so it goes before it, and catching CharterError first would swallow them all. DeclarationError is not in that block, and the trailing except CharterError is what catches it: most of them are raised while a tool is built, but the ones that need a request to be detected arrive here instead. Because it is also a ValueError, code that already guards a declaration with except ValueError keeps catching the build-time ones.

In an agent

A tool failing must end the call, never the agent. The OpenAI loop, the LangChain adapter and the MCP server hand a CharterError back to the model as the tool’s result, since its message is written to be read. Anything else — a bug in a response handler or a credential provider — is logged with its traceback on the charter logger, and the model is told the tool failed on its side, that its arguments were not the cause, and to tell the user instead of retrying. The exception’s own text is not passed on: it was written for nobody, and can quote internal state. A host running its own loop over Tool.ainvoke gets the same text from unexpected_failure_text: