Deuz SDK 2.0 is out — stores, guardrails, handoffs, and zero-config MCP. What is new in 2.0
Deuz SDK

Error Handling

The typed DeuzError taxonomy, the never-throw streaming contract, retry semantics, the four timeout layers, and the secret-redaction guarantee.

Provider, transport, and orchestration failures use the typed DeuzError contract rather than exposing a raw Response or a provider payload. Each wire adapter normalizes its provider's error envelope into the same set of classes, so retry, routing and fallback logic branches on err.code regardless of which provider produced the failure. Every error class is exported from the package root.

import { DeuzError, isDeuzError, RateLimitError, AuthenticationError } from '@deuz-sdk/core';

Prefer `isDeuzError` over `instanceof` at package boundaries

instanceof breaks across duplicate package copies and across realms (a worker, a bundled dependency that pinned its own version). isDeuzError(value) checks a Symbol.for-branded field instead, so it stays reliable. Use instanceof for the specific subclasses when you need their fields, and isDeuzError for the "is this one of ours?" question.

The taxonomy

DeuzError is the abstract base. Every error carries a stable string code; HTTP-shaped errors additionally extend APICallError and carry statusCode plus an isRetryable verdict.

APICallError and its subclasses

APICallError represents a non-2xx response from a provider (or an in-stream error envelope). Adapters map each wire's error type onto one of these.

ClasscodeStatusRetried by the SDKWhat you should do
APICallErrorapi_call_errormappedwhen >= 500Log provider / statusCode / requestId and treat as transient. Base class — also used directly for generic 5xx.
NetworkErrornetwork_error0DNS/TLS/transport died before an HTTP response. Nothing to fix in your request; check egress, proxies, and the base URL.
RateLimitErrorrate_limit429Read retryAfterMs and back off at your layer too — the SDK only retries within one call. Long term: lower concurrency or shard keys.
OverloadedErroroverloaded529Provider-side capacity, not you. This is the classic case for fallbackModels / withFallback to a second provider.
AuthenticationErrorauthentication401 (also 403)A bad, missing, expired or under-permissioned key. Never retry. Check the G1 resolution order — a deps.keyProvider outranks factory config, which outranks createClient's apiKeys.
InvalidRequestErrorinvalid_request400 / 422 / 413Your request is malformed — a bad message shape, an oversized payload, or a mutually exclusive option pair (prompt and messages). Fix the call; a retry sends the same bad bytes.
ModelNotFoundErrormodel_not_found404Wrong slug, wrong region, or the deployment does not exist (a common Azure/Vertex misconfiguration).
ContextOverflowErrorcontext_overflow400The history is too long. Inside the agentic loop this is auto-recovered once per step (see below); outside it, trim or compact and retry.

Every APICallError exposes the same fields:

FieldTypeDescription
statusCodenumberUpstream HTTP status (0 for NetworkError)
isRetryablebooleanWhether a retry could plausibly succeed
retryAfterMsnumber | undefinedParsed Retry-After, in milliseconds
providerstring | undefinedProvider id (anthropic, openai, xai, google, …)
requestIdstring | undefinedUpstream request id — the one thing a provider support ticket actually needs
upstreamTypestring | undefinedProvider's raw error type/code string, normalized for logging

Context overflow self-heals inside the loop (2.0)

When a step's request is rejected as too long, the agentic loop forces a compaction pass and retries that step once against the shrunk history — even if you never opted into compaction. A second overflow in the same step propagates verbatim, because compaction already gave what it could. So a ContextOverflowError reaching your catch from a loop call means compaction could not help, not that nothing was tried.

Non-HTTP errors

These extend DeuzError directly (no statusCode / isRetryable):

ClasscodeExtra fieldsWhat you should do
TimeoutErrortimeoutlayer: 'connect' | 'ttft' | 'total' | 'step' | 'tool'Branch on layer: 'ttft'/'total' means the provider was slow, 'step' means your agent ran long. Always a failure, never retried. See the four layers.
AbortErrorabortedCaller-initiated cancellation. Never retried, never falls back. In a stream this normally does not reach you at all — the run resolves finishReason: 'aborted' instead.
NoObjectGeneratedErrorno_object_generatedtext?: stringgenerateObject could not produce a valid object after one repair. Inspect .text (the raw model output) — usually the schema is too strict or the model too small.
UnsupportedCapabilityErrorunsupported_capabilityprovider, capability, modelId?A model lacks a requested capability (e.g. embeddings on a chat-only provider). Thrown before any network call, so it costs nothing. Pick a different model, or override the belief with capabilities.
BreakerOpenErrorbreaker_openprovider, modelId, cooldownUntilThe per-model circuit breaker is open — the SDK is failing fast without a request. Route to a fallback model, or wait until cooldownUntil (a deps.clock.now() timestamp). See the breaker.
McpAuthorizationRequiredErrormcp_authorization_requiredserverUrl, authorizationUrl?Step one of an OAuth flow, not a dead end. Send the user to authorizationUrl, then reconnect with authorizationCode set to the ?code= you get back. See MCP.
ToolExecutionErrortool_executiontoolName, toolCallId?Usually you never see this thrown — see below.

ToolExecutionError is constructed internally but, in the agentic loop, it is not thrown. Its message is fed back to the model as an is_error tool_result so the model can self-correct. Look for isError: true on a ToolResult, not for a rejection. See The Tool Loop.

`authorizationUrl` is deliberately absent from `toJSON()`

A live authorization request carries state and code_challenge, which do not belong in every log line. Read it off the instance, not off the serialized error.

Two failure channels, one rule

Which channel a failure uses is decided entirely by whether the entry point is a promise.

Entry pointFailure surfaces as
generateText, generateObject, embed, embedManyA rejected promise. Use try/catch.
streamChat, streamObjectAn error part on fullStream, a throwing textStream / partialObjectStream, and rejected usage / finishReason promises. Never a synchronous throw.

The never-throw contract for streamChat

streamChat returns a StreamChatResult synchronously and never throws — not on a missing API key, not on an invalid option pair. The network pump starts lazily on first access of any output. A failure surfaces in three coordinated ways:

  1. An { type: 'error', error } part is pushed onto fullStream.
  2. The usage promise rejects with that error.
  3. The finishReason promise rejects with that error.

Iterating textStream re-throws the error part, so a plain for await over text still lets you try/catch.

import { streamChat, isDeuzError } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

const result = streamChat({
  model: anthropic('claude-opus-4-8'),
  messages: [{ role: 'user', content: 'Explain backpressure.' }],
});

try {
  for await (const chunk of result.textStream) {
    process.stdout.write(chunk);
  }
} catch (err) {
  // textStream re-throws the error part as a typed DeuzError.
  if (isDeuzError(err)) console.error(err.code, err.message);
}

Do not leave `usage` unhandled

The usage and finishReason promises reject on failure. If you only iterate the stream and never await them, an unhandled rejection can take a Node process down. Either await result.usage.catch(...), or do not touch them at all — an untouched promise is not "unhandled" only if you never create a floating reference to it.

Reading error parts off fullStream

If you consume fullStream directly, handle the error part yourself — it will not throw on its own. Keep a default case: StreamPart is an open union.

import { streamChat, DeuzError } from '@deuz-sdk/core';
import { createOpenAI } from '@deuz-sdk/core/openai';

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY! });

const result = streamChat({
  model: openai('gpt-5.5'),
  messages: [{ role: 'user', content: 'hi' }],
});

for await (const part of result.fullStream) {
  switch (part.type) {
    case 'text-delta':
      process.stdout.write(part.text);
      break;
    case 'error':
      if (part.error instanceof DeuzError) {
        console.error(`[${part.error.code}]`, part.error.message);
      }
      break;
    default:
      break; // additive variants
  }
}

generateText and generateObject reject

import { generateObject, NoObjectGeneratedError } from '@deuz-sdk/core';
import { createOpenAI } from '@deuz-sdk/core/openai';
import { z } from 'zod';

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY! });

try {
  const { object } = await generateObject({
    model: openai('gpt-5.5'),
    schema: z.object({ city: z.string(), population: z.number() }),
    messages: [{ role: 'user', content: 'Largest city in Japan?' }],
  });
  console.log(object);
} catch (err) {
  if (err instanceof NoObjectGeneratedError) {
    // The raw model text that failed to parse/validate is on `.text`.
    console.error('No valid object. Raw output:', err.text);
  }
}

generateObject makes one repair retry on a parse/validation failure before throwing. See generateObject.

Catching typed errors

Branch on instanceof for class-specific fields, or on code for a flat switch. The same classes are used across every provider, so this logic is provider-agnostic.

import {
  generateText,
  AuthenticationError,
  RateLimitError,
  ContextOverflowError,
  BreakerOpenError,
  APICallError,
} from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

try {
  await generateText({
    model: anthropic('claude-opus-4-8'),
    messages: [{ role: 'user', content: 'hello' }],
  });
} catch (err) {
  if (err instanceof AuthenticationError) {
    // 401/403 — bad or insufficient credentials. Do not retry; fix the key.
  } else if (err instanceof RateLimitError) {
    console.log('retry after (ms):', err.retryAfterMs);
  } else if (err instanceof BreakerOpenError) {
    console.log('failing fast until', err.cooldownUntil); // route elsewhere
  } else if (err instanceof ContextOverflowError) {
    // Trim or compact the prompt and try again.
  } else if (err instanceof APICallError) {
    console.error(err.provider, err.statusCode, err.requestId);
  } else {
    throw err; // never swallow what you did not classify
  }
}

Checking retryability

isRetryable lives on APICallError and its subclasses. Errors that are not HTTP-shaped (TimeoutError, AbortError, NoObjectGeneratedError, UnsupportedCapabilityError, BreakerOpenError) do not carry the flag and are final.

import { APICallError } from '@deuz-sdk/core';

function isRetryable(err: unknown): boolean {
  return err instanceof APICallError && err.isRetryable;
}

Retry interplay

The pump retries only before the first byte of the response stream. Once streaming begins, a mid-stream error is final — partial output has already been emitted, so a transparent retry would either duplicate tokens or splice an unrelated completion into the middle of the first.

AspectBehaviour
WhenPre-first-byte only
BudgetmaxRetries, default 2 (per-call override on CommonCallOptions)
BackoffExponential with full jitter: random() * min(cap, base * 2^attempt), base 500ms, cap 30s
Retry-AfterHonoured when the provider sends it (capped at 30s); takes precedence over computed backoff
Which errorsThose whose isRetryable is trueNetworkError, RateLimitError (429), OverloadedError (529), and APICallError with status >= 500
Which errors neverTimeoutError and AbortError — both are re-thrown immediately. A 4xx other than 429 is never retried either.
DeterminismJitter is derived from deps.generateId() (hashed to a unit interval), never Math.random() — reproducible in tests
Aborting during backoffYour signal interrupts the wait immediately; it does not sleep out the delay first
const result = streamChat({
  model: anthropic('claude-opus-4-8'),
  messages: [{ role: 'user', content: 'hi' }],
  maxRetries: 4, // raise the pre-first-byte budget for this call
});

"Full jitter" rather than a fixed backoff matters at scale: when a provider returns 429 to a hundred of your requests at once, a deterministic delay makes all hundred retry at the same instant and produce a second thundering herd. Randomizing the whole interval spreads them.

The circuit breaker

Retries protect one call. The breaker protects the next ones from a provider that is already down.

  • Keyed per provider:modelId and stored in deps.breakerStore — so it is per-client, not global. Two createClient instances do not share a verdict.
  • Only provider-health failures count: NetworkError, TimeoutError, and retryable / >= 500 APICallErrors. A 401 or a 400 says nothing about provider health and is ignored.
  • 5 consecutive countable failures open it. It then fails fast with BreakerOpenError for a 30s cooldown instead of sending a request.
  • A successful response resets the counter (checked the moment a body arrives, before streaming) — best-effort, and only when there was something to clear.
  • A failing breaker store never blocks calls — if the store throws, the call proceeds.

fallbackModels and the withFallback middleware treat BreakerOpenError as an immediate fail-over signal, which is the point: the fast failure is what makes fail-over cheap. Full detail on Resilience.

The four timeout layers

Each layer answers a different question, so each has its own scope. All are settable per call via timeout, and every timer is scheduled through deps.clock — never an ambient host timer — so tests stay deterministic.

LayerScopeDefaultFires whenCleared by
ttftMsone model call60_000the first content delta has not arrived in timethe first text-delta, reasoning-delta or tool-call-delta — a tool-call-first response counts as content
totalMsone model call300_000that whole response takes too longthe call completing
stepMsone agentic step end-to-end — the model call plus the tool executions it triggeredunboundedthe step overrunsthe step ending
toolMsone tool execute (per call, not per step)unboundedthat execution overruns; Tool.timeoutMs overrides it for a single toolthe execution returning

Notes that save debugging time:

  • An explicit 0 disables a layer. timeout: { totalMs: 0 } is how you opt out of the 300s ceiling that a 25-second serverless budget makes meaningless.
  • ttftMs is not a total budget. A slow-but-alive stream is fine: the timer is cleared the moment any content arrives.
  • stepMs is enforced by both loops — the streaming one and the buffered generateText one arm it identically, so the same option cannot mean two things.
  • A stepMs expiry that fires while tools are running cannot abort the tools. That is toolMs / Tool.timeoutMs. The loop checks the deadline at its own step boundaries and fails there rather than feeding results into a model call it can no longer pay for.
  • A tool timeout is self-healing, not fatal. The execution is abandoned (its signal is aborted, so a well-behaved tool passing it to fetch stops working), and the model receives an is_error tool_resultTool 'x' timed out after 30000ms and was abandoned. Every tool_use_id still gets a result. Tool timeouts do count toward the runaway-tool guard.

`layer` values you will actually see

The union declares five members, but only three are constructed today: 'ttft', 'total' and 'step'. 'connect' is reserved, and a per-tool timeout currently reports layer: 'total' with a Tool execution exceeded …ms message rather than 'tool'. Match on the message or on the surrounding tool-result if you need to tell those apart; treat the union as open.

Abort is not a timeout

They look similar and are deliberately kept apart, because "the user changed their mind" and "the provider never answered" call for different handling.

User abort (signal.abort())Timeout
finishReasonresolves 'aborted'promise rejects
usageresolves with partial usagepromise rejects
error part on fullStreamnoneyes, carrying a TimeoutError
onUsagefires with meta.reason === 'aborted'fires with meta.reason === 'error'
Retriednevernever
Is it a failure?no — it is a clean early endyes

The SDK goes out of its way to keep this true even when a transport misbehaves: if one of our abort sources cut the request but the transport reported only a bare AbortError, the armed TimeoutError is recovered and reported instead of being misread as a user cancel.

Secret redaction (P0)

API keys must never appear in any log, error message, tracer attribute, observation event, or cause chain. This is a regression-tested invariant, not a best effort.

  • DeuzError deliberately carries no raw request headers or body, and never places a raw Request/Headers in cause.
  • error.toJSON() returns only name, code, message, and an allowlist of scalar diagnostic fields (statusCode, isRetryable, retryAfterMs, provider, requestId, upstreamType, layer, toolName, toolCallId, modelId, capability, runId, mime, serverUrl). cause and model output are excluded.
  • Anything that flows into a logger, error, or span first passes through the redaction helpers. Secret-looking header values (authorization, x-api-key, x-goog-api-key, api-key) and token shapes (sk-ant-…, sk-…, AIza…, Bearer …) are masked to their last four characters****AB12.
  • Opt-in observation content capture passes through a second, stricter [REDACTED] profile before an event is emitted.

You never call the redaction helpers yourself; they run inside the SDK. The takeaway: it is safe to log a DeuzError verbatim — including provider, statusCode, requestId and upstreamType — without leaking credentials.

import { generateText, isDeuzError } from '@deuz-sdk/core';

try {
  await generateText({ model, prompt: 'hi' });
} catch (err) {
  // toJSON() is the secret-free projection: name, code, message + scalar details.
  console.error('inference failed', isDeuzError(err) ? err.toJSON() : { message: String(err) });
}

See also

On this page