Skip to content

AI Errors

Every AI operation in Effect fails with a single, unified error type: AiError. Instead of a sprawling union of provider-specific error classes leaking into your Effect’s error channel, AiError is a thin wrapper that carries:

  • module and method — where the failure originated (e.g. "OpenAiLanguageModel", "generateText").
  • reason — a semantic, tagged error describing what went wrong (rate limited, bad auth, invalid output, a tool failure, …).

This is the reason pattern: one error tag (AiError) in your type signatures, with a rich, discriminated reason inside it. You catch the wrapper once and branch on the reason, or use the dedicated Effect.catchReason helper to narrow straight to a reason.

import { Effect } from "effect"
import { LanguageModel } from "effect/unstable/ai"
import { AiError } from "effect/unstable/ai"
const program = LanguageModel.generateText({
prompt: "Write a haiku about TypeScript"
}).pipe(
// generateText fails with AiError.AiError
Effect.catchTag("AiError", (error) =>
Effect.logError(`[${error.module}.${error.method}] ${error.reason._tag}: ${error.message}`)
)
)

Because the error channel only ever contains AiError, Effect.catchTag with "AiError" recovers from all AI failures in one place. Switch on reason._tag to decide what to do:

import { Effect } from "effect"
import { LanguageModel, AiError } from "effect/unstable/ai"
const summarize = (text: string) =>
LanguageModel.generateText({ prompt: text }).pipe(
Effect.map((response) => response.text),
Effect.catchTag("AiError", (error) => {
switch (error.reason._tag) {
case "RateLimitError":
// retryAfter is surfaced on both the reason and the wrapper
return Effect.fail(`Slow down — retry after ${error.retryAfter}`)
case "AuthenticationError":
return Effect.fail(`Bad credentials (${error.reason.kind})`)
case "ContentPolicyError":
return Effect.succeed("[redacted — content policy]")
default:
return Effect.fail(error.message)
}
})
)

Catching a single reason with Effect.catchReason

Section titled “Catching a single reason with Effect.catchReason”

When you only care about one reason, Effect.catchReason lets you skip the manual switch and narrow directly. It takes the error tag, the reason tag, a handler that receives the narrowed reason, and an optional fallback for the other reasons:

import { Effect } from "effect"
import { LanguageModel } from "effect/unstable/ai"
const resilient = LanguageModel.generateText({
prompt: "Explain backpressure"
}).pipe(
// `reason` here is narrowed to AiError.RateLimitError
Effect.catchReason(
"AiError",
"RateLimitError",
(reason) =>
// reason.retryAfter is a Duration | undefined
Effect.logWarning(`rate limited`).pipe(
Effect.zipRight(Effect.fail("rate limited"))
),
// all other reasons pass through unchanged
(otherReason, error) => Effect.fail(error)
)
)

Retrying transient failures with isRetryable

Section titled “Retrying transient failures with isRetryable”

Every reason exposes an isRetryable getter, and the AiError wrapper delegates to it. Combine it with Effect.retry to back off only on transient errors (network blips, 5xx, rate limits, non-deterministic output) and fail fast on permanent ones (bad auth, content policy, malformed requests):

import { Effect, Schedule } from "effect"
import { LanguageModel, AiError } from "effect/unstable/ai"
const withRetry = LanguageModel.generateText({
prompt: "Generate a slogan"
}).pipe(
Effect.retry({
schedule: Schedule.exponential("200 millis"),
times: 4,
// only retry when the underlying reason is transient
while: (error: AiError.AiError) => error.isRetryable
})
)

Reading HTTP context, provider metadata, and usage

Section titled “Reading HTTP context, provider metadata, and usage”

Provider-facing reasons attach extra forensic context. Reach into reason to pull it out:

  • reason.http — an HttpContext (request details, optional response details, raw body) on provider/transport reasons.
  • reason.metadataProviderMetadata, namespaced per provider (e.g. metadata.openai).
  • reason.usageUsageInfo (token counts) on output reasons like InvalidOutputError.
import { Effect } from "effect"
import { LanguageModel } from "effect/unstable/ai"
const logContext = LanguageModel.generateText({ prompt: "Hi" }).pipe(
Effect.catchTag("AiError", (error) => {
const reason = error.reason
// Provider HTTP context (present on most provider reasons)
if ("http" in reason && reason.http) {
const { request, response } = reason.http
Effect.runSync(
Effect.logError(
`HTTP ${response?.status ?? "?"} for ${request.method} ${request.url}`
)
)
}
// Provider-specific metadata, keyed by provider name
if ("metadata" in reason) {
const openai = reason.metadata.openai // MutableJson | null | undefined
console.log("openai metadata:", openai)
}
// Token usage (on output reasons)
if ("usage" in reason && reason.usage) {
console.log("tokens:", reason.usage.totalTokens)
}
return Effect.fail(error)
})
)

All of the following are exported from effect/unstable/ai via the AiError namespace.

The top-level wrapper error — the only error type AI operations put in their error channel. It carries module, method, and the semantic reason, and delegates isRetryable / retryAfter to that reason. Its message is formatted as "<module>.<method>: <reason message>".

import { Duration } from "effect"
import { AiError } from "effect/unstable/ai"
const error = new AiError.AiError({
module: "OpenAi",
method: "generateText",
reason: new AiError.RateLimitError({ retryAfter: Duration.seconds(60) })
})
error._tag // => "AiError"
error.isRetryable // => true (delegated to the reason)
error.retryAfter // => Duration("60 seconds")
error.message // => "OpenAi.generateText: Rate limit exceeded. Retry after 1m"

Constructor for an AiError from a module, method, and reason. Equivalent to new AiError.AiError({ ... }); prefer it when constructing errors inside provider integrations.

import { AiError } from "effect/unstable/ai"
const error = AiError.make({
module: "Anthropic",
method: "completion",
reason: new AiError.QuotaExhaustedError({})
})
error.message // => "Anthropic.completion: Quota exhausted. Check your account billing and usage limits."

Both a TypeScript union type and a runtime Schema.Union of every concrete reason class below. Use the type to annotate handlers, and the schema to decode/validate an unknown reason value.

import { Schema } from "effect"
import { AiError } from "effect/unstable/ai"
// As a type
const handle = (reason: AiError.AiErrorReason) => reason._tag
// As a schema (decode an unknown reason)
const decode = Schema.decodeUnknownSync(AiError.AiErrorReason)

The serialized (JSON-encoded) form of an AiError — useful when sending errors across a wire boundary (RPC, workers) where they are encoded/decoded via Schema.

import { AiError } from "effect/unstable/ai"
type Encoded = AiError.AiErrorEncoded
// => { _tag: "AiError"; module: string; method: string; reason: ... }

Type guard narrowing an unknown value to AiError. Handy at boundaries where the error type has been widened to unknown.

import { AiError } from "effect/unstable/ai"
const aiError = AiError.make({
module: "Test",
method: "x",
reason: new AiError.RateLimitError({})
})
AiError.isAiError(aiError) // => true
AiError.isAiError(new Error("oops")) // => false

Type guard narrowing an unknown value to one of the AiErrorReason classes (i.e. a value carrying the reason brand).

import { AiError } from "effect/unstable/ai"
AiError.isAiErrorReason(new AiError.RateLimitError({})) // => true
AiError.isAiErrorReason(new Error("oops")) // => false

Maps an HTTP status code to the appropriate semantic reason. Provider packages use it as a base mapping (400 → InvalidRequestError, 401 → AuthenticationError InvalidKey, 403 → AuthenticationError InsufficientPermissions, 429 → RateLimitError, ≥500 → InternalProviderError, otherwise → UnknownError).

import { AiError } from "effect/unstable/ai"
AiError.reasonFromHttpStatus({ status: 429 })._tag // => "RateLimitError"
AiError.reasonFromHttpStatus({ status: 401 })._tag // => "AuthenticationError"
AiError.reasonFromHttpStatus({ status: 503 })._tag // => "InternalProviderError"
AiError.reasonFromHttpStatus({ status: 418 })._tag // => "UnknownError"

These reasons describe failures coming from (or before reaching) the provider. Most carry optional http context and provider metadata.

A transport-level failure that occurred before an HTTP response was received: connectivity loss, request encoding failure, or an invalid URL. Discriminated by reason ("TransportError" is retryable; "EncodeError" and "InvalidUrlError" are not). Build one from a platform HttpClientError.RequestError with NetworkError.fromRequestError.

import { AiError } from "effect/unstable/ai"
const error = new AiError.NetworkError({
reason: "TransportError",
request: {
method: "POST",
url: "https://api.openai.com/v1/completions",
urlParams: [],
hash: undefined,
headers: { "Content-Type": "application/json" }
},
description: "Connection timeout after 30 seconds"
})
error.isRetryable // => true (only TransportError is retryable)
error.message // => "TransportError: Connection timeout after 30 seconds (POST .../v1/completions) ..."

The request was throttled (typically a 429). Always retryable; when the provider supplies a retryAfter Duration, wait that long before retrying. Carries provider metadata and optional http context.

import { Duration } from "effect"
import { AiError } from "effect/unstable/ai"
const error = new AiError.RateLimitError({ retryAfter: Duration.seconds(60) })
error.isRetryable // => true
error.retryAfter // => Duration("60 seconds")
error.message // => "Rate limit exceeded. Retry after 1m"

Account or billing limits have been reached. Not retryable without user action; an optional resetAt DateTimeUtc indicates when the quota refreshes.

import { AiError } from "effect/unstable/ai"
const error = new AiError.QuotaExhaustedError({})
error.isRetryable // => false
error.message // => "Quota exhausted. Check your account billing and usage limits."

Invalid, expired, missing, or under-privileged credentials. Never retryable without changing credentials. The kind field is one of "InvalidKey", "ExpiredKey", "MissingKey", "InsufficientPermissions", "Unknown" and drives the suggestion in message.

import { AiError } from "effect/unstable/ai"
const error = new AiError.AuthenticationError({ kind: "ExpiredKey" })
error.isRetryable // => false
error.message // => "ExpiredKey: Your API key has expired. Generate a new one"

Input or output violated the provider’s content policy. Never retryable without changing the content. The description field is required and explains the violation.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ContentPolicyError({
description: "Input contains prohibited content"
})
error.isRetryable // => false
error.message // => "Content policy violation: Input contains prohibited content"

The request had malformed or out-of-range parameters. Not retryable — fix the request. Optional parameter, constraint, and description fields compose into the message.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InvalidRequestError({
parameter: "temperature",
constraint: "must be between 0 and 2",
description: "Temperature value 5 is out of range"
})
error.isRetryable // => false
error.message // => "Invalid request: parameter 'temperature' must be between 0 and 2. Temperature value 5 is out of range"

The provider hit an internal error (typically 5xx). Retryable, since these are usually transient. The description field is required.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InternalProviderError({
description: "Server encountered an unexpected error"
})
error.isRetryable // => true
error.message // => "Internal provider error: Server encountered an unexpected error"

The catch-all for failures that don’t map to any other reason. Not retryable by default. Carries an optional description, provider metadata, and optional http context.

import { AiError } from "effect/unstable/ai"
const error = new AiError.UnknownError({ description: "An unexpected error occurred" })
error.isRetryable // => false
error.message // => "An unexpected error occurred"

These describe failures interpreting the model’s output. Because LLM output is non-deterministic, all three carry optional usage info and are retryable.

The model’s output could not be parsed or validated. Retryable. Build one from a Schema.SchemaError with InvalidOutputError.fromSchemaError.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InvalidOutputError({
description: "Expected a string but received a number"
})
error.isRetryable // => true
error.message // => "Invalid output: Expected a string but received a number"

The model produced text that does not conform to the requested structured-output schema (e.g. generateObject). Retryable. Carries the offending responseText, and can be built from a schema error with StructuredOutputError.fromSchemaError(error, responseText).

import { AiError } from "effect/unstable/ai"
const error = new AiError.StructuredOutputError({
description: "Expected a valid JSON object",
responseText: '{"foo":}'
})
error.isRetryable // => true
error.responseText // => '{"foo":}'
error.message // => "Structured output validation failed: Expected a valid JSON object"

A codec transformer rejected your output schema because it contains constructs the provider cannot represent (e.g. certain unions). Not retryable — this is a programmer error to fix in code.

import { AiError } from "effect/unstable/ai"
const error = new AiError.UnsupportedSchemaError({
description: "Unions are not supported in Anthropic structured output"
})
error.isRetryable // => false
error.message // => "Unsupported schema: Unions are not supported in Anthropic structured output"

These describe failures in the tool-calling flow. Tool reasons do not carry http context. Reasons the model can self-correct (bad tool name, bad params) are retryable; bugs in your handlers or configuration are not.

The model asked to call a tool that isn’t in the toolkit. Retryable, since the model may correct itself given the list of available tools. Carries toolName and availableTools.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ToolNotFoundError({
toolName: "unknownTool",
availableTools: ["GetWeather", "GetTime"]
})
error.isRetryable // => true
error.message // => "Tool 'unknownTool' not found. Available tools: GetWeather, GetTime"

The model’s tool-call arguments failed schema validation. Retryable — the model may produce valid parameters on a retry. Carries toolName, the raw toolParams JSON, and a description.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ToolParameterValidationError({
toolName: "GetWeather",
toolParams: { location: 123 },
description: "Expected string, got number"
})
error.isRetryable // => true
error.message // => "Invalid parameters for tool 'GetWeather': Expected string, got number"

A tool handler returned a value that doesn’t match the tool’s declared result schema. Not retryable — it signals a bug in your handler. Carries toolName and description.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InvalidToolResultError({
toolName: "GetWeather",
description: "missing 'temperature' field"
})
error.isRetryable // => false
error.message // => "Tool 'GetWeather' returned invalid result: missing 'temperature' field"

A tool result could not be encoded back into JSON to send to the model. Not retryable — fix the tool’s result schema. Carries toolName, the raw toolResult, and a description.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ToolResultEncodingError({
toolName: "GetWeather",
toolResult: { temperature: 72n },
description: "Cannot encode bigint values as JSON"
})
error.isRetryable // => false
error.message // => "Failed to encode result for tool 'GetWeather': Cannot encode bigint values as JSON"

A provider-defined tool was configured with invalid arguments. Not retryable — fix the configuration in code. Carries toolName and description.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ToolConfigurationError({
toolName: "OpenAiCodeInterpreter",
description: "Invalid container ID format"
})
error.isRetryable // => false
error.message // => "Invalid configuration for tool 'OpenAiCodeInterpreter': Invalid container ID format"

An operation needed a toolkit to resolve pending tool-approval responses, but none was provided. Not retryable without supplying a toolkit. Carries pendingApprovals and an optional description.

import { AiError } from "effect/unstable/ai"
const error = new AiError.ToolkitRequiredError({
pendingApprovals: ["GetWeather", "SendEmail"]
})
error.isRetryable // => false
error.message // => "Toolkit required to resolve pending tool approvals: GetWeather, SendEmail"

The prompt contained content that is structurally valid but unsupported by the provider (e.g. an unsupported media type or file format). Not retryable — fix the input.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InvalidUserInputError({
description: "Unsupported media type 'video/mp4'. Supported types include images, application/pdf, text/plain"
})
error.isRetryable // => false
error.message // => "Invalid user input: Unsupported media type 'video/mp4'. ..."

These schemas are attached to reasons to carry extra context. They are plain Effect Schemas, so they decode/encode cleanly across wire boundaries.

A Record<string, MutableJson | null> keyed by provider name. Lets a reason carry arbitrary provider-specific detail (error codes, request IDs) without leaking provider types into the public API. Attached as metadata on most provider reasons.

import { AiError } from "effect/unstable/ai"
const error = new AiError.RateLimitError({
metadata: {
openai: { errorCode: "rate_limit_exceeded", requestId: "req_123" },
anthropic: null
}
})
error.metadata.openai // => { errorCode: "rate_limit_exceeded", requestId: "req_123" }

Optional provider-reported token counts: promptTokens, completionTokens, totalTokens. Attached as usage on output reasons (InvalidOutputError, StructuredOutputError) so you can account for tokens even on a failed generation.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InvalidOutputError({
description: "bad output",
usage: { promptTokens: 120, completionTokens: 0, totalTokens: 120 }
})
error.usage?.totalTokens // => 120

The captured HTTP exchange behind a provider error: the required request (HttpRequestDetails: method, url, urlParams, hash, headers), an optional response (HttpResponseDetails: status and headers), and an optional raw body string. Attached as http on provider/transport reasons.

import { AiError } from "effect/unstable/ai"
const error = new AiError.InternalProviderError({
description: "Server error",
http: {
request: {
method: "POST",
url: "https://api.openai.com/v1/chat/completions",
urlParams: [],
hash: undefined,
headers: { "Content-Type": "application/json" }
},
response: {
status: 503,
headers: { "X-Request-Id": "req_abc123" }
},
body: '{"error":"service unavailable"}'
}
})
error.http?.response?.status // => 503
  • Language Model — the operations (generateText, generateObject, streaming) that fail with AiError.
  • Tools — defining toolkits; tool failures surface as the tool reasons above.