From 4c997ca8fbde73637dd2eef3ed8e67d02f13b879 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 12:02:00 +0000 Subject: [PATCH 1/2] Add TypeScript coverage to how-to guides and create TS error handling doc Three major how-to guides (programmatic usage, error handling, testing) previously covered only Python and Java with zero TypeScript content. This adds comprehensive TypeScript sections to all three, creates a dedicated TypeScript error handling guide (mirroring the existing Python-specific guide), and adds cross-reference links throughout. - Add TypeScript sections to using-the-cycles-client-programmatically.md - Add TypeScript sections to error-handling-patterns-in-cycles-client-code.md - Create error-handling-patterns-in-typescript.md (Express, Next.js patterns) - Add TypeScript section to testing-with-cycles.md (Vitest, mocking patterns) - Add cross-reference links in quickstart and existing guides https://claude.ai/code/session_01AioSM8RvRKCXgRsV6m75Mm --- ...handling-patterns-in-cycles-client-code.md | 231 ++++++++++++- .../error-handling-patterns-in-typescript.md | 284 ++++++++++++++++ how-to/testing-with-cycles.md | 316 +++++++++++++++++- ...sing-the-cycles-client-programmatically.md | 212 +++++++++++- ...ting-started-with-the-typescript-client.md | 6 +- 5 files changed, 1038 insertions(+), 11 deletions(-) create mode 100644 how-to/error-handling-patterns-in-typescript.md diff --git a/how-to/error-handling-patterns-in-cycles-client-code.md b/how-to/error-handling-patterns-in-cycles-client-code.md index ee012fa..ba69fed 100644 --- a/how-to/error-handling-patterns-in-cycles-client-code.md +++ b/how-to/error-handling-patterns-in-cycles-client-code.md @@ -4,9 +4,11 @@ This guide covers practical patterns for handling Cycles errors in your applicat For Python-specific patterns (exception hierarchy, FastAPI integration), see [Error Handling in Python](/how-to/error-handling-patterns-in-python). +For TypeScript-specific patterns (exception hierarchy, Express/Next.js integration), see [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript). + ## Protocol error structure -Both the Python and Java clients expose structured error information when the server returns a protocol-level error. +The Python, Java, and TypeScript clients all expose structured error information when the server returns a protocol-level error. ### Python — CyclesProtocolError @@ -52,6 +54,30 @@ public class CyclesProtocolException extends RuntimeException { } ``` +### TypeScript — CyclesProtocolError + +```typescript +import { CyclesProtocolError } from "runcycles"; + +// Available properties: +e.status; // HTTP status code (e.g. 409) +e.errorCode; // Machine-readable error code (e.g. "BUDGET_EXCEEDED") +e.reasonCode; // Reason code string +e.retryAfterMs; // Suggested retry delay in ms (or undefined) +e.requestId; // Server request ID +e.details; // Additional error details object + +// Convenience checks: +e.isBudgetExceeded(); +e.isOverdraftLimitExceeded(); +e.isDebtOutstanding(); +e.isReservationExpired(); +e.isReservationFinalized(); +e.isIdempotencyMismatch(); +e.isUnitMismatch(); +e.isRetryable(); +``` + ## Handling DENY decisions When a reservation is denied, the decorated function / annotated method does not execute. An exception is thrown instead. @@ -94,6 +120,30 @@ try { } ``` +### TypeScript + +```typescript +import { withCycles, BudgetExceededError, CyclesProtocolError } from "runcycles"; + +const summarize = withCycles( + { estimate: 1000, actionKind: "llm.completion", actionName: "gpt-4o", client }, + async (text: string) => callLlm(text), +); + +try { + result = await summarize(text); +} catch (err) { + if (err instanceof BudgetExceededError) { + result = "Service temporarily unavailable due to budget limits."; + } else if (err instanceof CyclesProtocolError && err.retryAfterMs) { + scheduleRetry(text, err.retryAfterMs); + result = `Request queued. Retrying in ${err.retryAfterMs}ms.`; + } else { + throw err; + } +} +``` + ## Degradation patterns ### Python @@ -120,6 +170,22 @@ try { } ``` +### TypeScript + +```typescript +import { BudgetExceededError } from "runcycles"; + +try { + result = await premiumService.analyze(data); // GPT-4o, high cost +} catch (err) { + if (err instanceof BudgetExceededError) { + result = await basicService.analyze(data); // GPT-4o-mini, lower cost + } else { + throw err; + } +} +``` + ## Handling debt and overdraft errors ### DebtOutstandingError / DEBT_OUTSTANDING @@ -154,6 +220,24 @@ try { } ``` +**TypeScript:** + +```typescript +import { DebtOutstandingError } from "runcycles"; + +try { + result = await process(inputData); +} catch (err) { + if (err instanceof DebtOutstandingError) { + console.warn("Scope has outstanding debt. Notifying operator."); + alertOperator("Budget debt detected. Funding required."); + result = "Service paused pending budget review."; + } else { + throw err; + } +} +``` + ### OverdraftLimitExceededError / OVERDRAFT_LIMIT_EXCEEDED The scope's debt has exceeded its overdraft limit. @@ -184,6 +268,23 @@ try { } ``` +**TypeScript:** + +```typescript +import { OverdraftLimitExceededError } from "runcycles"; + +try { + result = await process(inputData); +} catch (err) { + if (err instanceof OverdraftLimitExceededError) { + console.error("Overdraft limit exceeded. Scope is blocked."); + result = "Budget limit reached. Please contact support."; + } else { + throw err; + } +} +``` + ## Handling expired reservations If a function takes longer than the reservation TTL plus grace period, the commit will fail with `RESERVATION_EXPIRED`. Both clients handle heartbeat extensions automatically, but network issues can prevent extensions. @@ -219,6 +320,26 @@ try { } ``` +**TypeScript:** + +```typescript +import { ReservationExpiredError } from "runcycles"; + +try { + result = await longRunningProcess(data); +} catch (err) { + if (err instanceof ReservationExpiredError) { + console.warn( + "Reservation expired during processing. " + + "Consider increasing ttlMs or checking network connectivity.", + ); + await recordAsEvent(data); + } else { + throw err; + } +} +``` + ## Catching all Cycles errors ### Python @@ -275,6 +396,42 @@ try { } ``` +### TypeScript + +```typescript +import { + BudgetExceededError, + DebtOutstandingError, + OverdraftLimitExceededError, + ReservationExpiredError, + CyclesProtocolError, + CyclesTransportError, +} from "runcycles"; + +try { + result = await guardedFunc(); +} catch (err) { + if (err instanceof BudgetExceededError) { + result = await fallback(); + } else if (err instanceof DebtOutstandingError) { + alertOperator("Debt outstanding"); + result = "Service paused"; + } else if (err instanceof OverdraftLimitExceededError) { + result = "Budget limit reached"; + } else if (err instanceof ReservationExpiredError) { + await recordAsEvent(data); + } else if (err instanceof CyclesTransportError) { + console.error(`Transport error: ${err.message} (cause=${err.cause})`); + throw err; + } else if (err instanceof CyclesProtocolError) { + console.error(`Protocol error: ${err.message} (code=${err.errorCode}, status=${err.status})`); + throw err; + } else { + throw err; + } +} +``` + ## Web framework error handlers ### Python (FastAPI) @@ -346,6 +503,55 @@ public class CyclesExceptionHandler { } ``` +### TypeScript (Express) + +```typescript +import type { Request, Response, NextFunction } from "express"; +import { CyclesProtocolError } from "runcycles"; + +function cyclesErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) { + if (!(err instanceof CyclesProtocolError)) { + return next(err); + } + + if (err.isBudgetExceeded()) { + const retryAfter = err.retryAfterMs ? Math.ceil(err.retryAfterMs / 1000) : 60; + return res.status(429) + .set("Retry-After", String(retryAfter)) + .json({ error: "budget_exceeded", message: "Budget limit reached." }); + } + + if (err.isDebtOutstanding() || err.isOverdraftLimitExceeded()) { + return res.status(503) + .json({ error: "service_unavailable", message: "Service paused due to budget constraints." }); + } + + return res.status(500) + .json({ error: "internal_error", message: "An unexpected error occurred." }); +} +``` + +### TypeScript (Next.js API Route) + +```typescript +import { BudgetExceededError } from "runcycles"; + +export async function POST(req: Request) { + try { + const result = await handleChat(req); + return new Response(result); + } catch (err) { + if (err instanceof BudgetExceededError) { + return new Response( + JSON.stringify({ error: "budget_exceeded", message: "Budget limit reached." }), + { status: 402, headers: { "Content-Type": "application/json" } }, + ); + } + throw err; + } +} +``` + ## Programmatic client error handling When using the client directly, errors come as response status codes rather than exceptions. @@ -402,6 +608,26 @@ if (response.is2xx()) { } ``` +### TypeScript + +```typescript +const response = await client.createReservation(request); + +if (response.isSuccess) { + const reservationId = response.getBodyAttribute("reservation_id") as string; + // Proceed with work +} else if (response.isServerError || response.isTransportError) { + // Server error or network failure — retry with backoff + console.warn(`Cycles server error: ${response.errorMessage}`); +} else { + // Client error (4xx) — do not retry + // 409 = budget exceeded, debt outstanding, overdraft limit exceeded + // 400 = invalid request, unit mismatch + // 410 = reservation expired + console.error(`Cycles client error: status=${response.status}, error=${response.errorMessage}`); +} +``` + ## Transient vs non-transient errors | Error | Retryable? | Action | @@ -420,7 +646,7 @@ if (response.is2xx()) { | `INTERNAL_ERROR` (500) | Yes | Retry with exponential backoff. | | Transport error | Yes | Retry with exponential backoff. | -In Python, use `e.is_retryable()` to check programmatically — it returns `True` for `INTERNAL_ERROR`, `UNKNOWN`, and any 5xx status. +In Python and TypeScript, use `e.is_retryable()` / `e.isRetryable()` to check programmatically — it returns `true` for `INTERNAL_ERROR`, `UNKNOWN`, and any 5xx status. ## Error handling checklist @@ -435,6 +661,7 @@ In Python, use `e.is_retryable()` to check programmatically — it returns `True ## Next steps +- [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript) — TypeScript exception hierarchy, Express/Next.js patterns - [Error Handling in Python](/how-to/error-handling-patterns-in-python) — Python exception hierarchy, transport errors, and FastAPI patterns - [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — protocol error code reference - [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer) — strategies for handling budget constraints diff --git a/how-to/error-handling-patterns-in-typescript.md b/how-to/error-handling-patterns-in-typescript.md new file mode 100644 index 0000000..bfaec73 --- /dev/null +++ b/how-to/error-handling-patterns-in-typescript.md @@ -0,0 +1,284 @@ +# Error Handling Patterns in TypeScript + +This guide covers practical patterns for handling Cycles errors in TypeScript applications — with `withCycles`, `reserveForStream`, and the programmatic `CyclesClient`. + +## Exception hierarchy + +The `runcycles` package provides a typed exception hierarchy: + +``` +CyclesError (base) +├── CyclesProtocolError (server returned a protocol-level error) +│ ├── BudgetExceededError +│ ├── OverdraftLimitExceededError +│ ├── DebtOutstandingError +│ ├── ReservationExpiredError +│ └── ReservationFinalizedError +└── CyclesTransportError (network-level failure) +``` + +## CyclesProtocolError + +When `withCycles` or `reserveForStream` encounters a DENY decision or a protocol error, it throws `CyclesProtocolError` (or a specific subclass): + +```typescript +import { CyclesProtocolError } from "runcycles"; + +// Available properties: +e.status; // HTTP status code (e.g. 409) +e.errorCode; // Machine-readable error code (e.g. "BUDGET_EXCEEDED") +e.reasonCode; // Reason code string +e.retryAfterMs; // Suggested retry delay in ms (or undefined) +e.requestId; // Server request ID +e.details; // Additional error details (Record) + +// Convenience checks: +e.isBudgetExceeded(); +e.isOverdraftLimitExceeded(); +e.isDebtOutstanding(); +e.isReservationExpired(); +e.isReservationFinalized(); +e.isIdempotencyMismatch(); +e.isUnitMismatch(); +e.isRetryable(); // true for INTERNAL_ERROR, UNKNOWN, or 5xx status +``` + +## CyclesTransportError + +Thrown when the HTTP connection itself fails (DNS failure, timeout, connection refused): + +```typescript +import { CyclesTransportError } from "runcycles"; + +try { + result = await guardedFunc(); +} catch (err) { + if (err instanceof CyclesTransportError) { + console.error(`Transport error: ${err.message}`); + console.error(`Cause: ${err.cause}`); + } +} +``` + +## Catching errors from withCycles + +`withCycles` wraps a function with the full reserve → execute → commit lifecycle. If the reservation is denied, it throws before your function runs: + +```typescript +import { withCycles, BudgetExceededError, CyclesProtocolError } from "runcycles"; + +const summarize = withCycles( + { estimate: 1000, actionKind: "llm.completion", actionName: "gpt-4o", client }, + async (text: string) => callLlm(text), +); + +try { + const result = await summarize(text); +} catch (err) { + if (err instanceof BudgetExceededError) { + // Budget exhausted — degrade or queue + return fallbackSummary(text); + } else if (err instanceof CyclesProtocolError) { + // Other protocol error + if (err.retryAfterMs) { + scheduleRetry(text, err.retryAfterMs); + return `Request queued. Retrying in ${err.retryAfterMs}ms.`; + } + throw err; + } else { + throw err; + } +} +``` + +## Catching errors from reserveForStream + +`reserveForStream` throws on reservation failure. After a successful reservation, you must handle errors from the stream itself and release the handle: + +```typescript +import { reserveForStream, BudgetExceededError } from "runcycles"; + +let handle; +try { + handle = await reserveForStream({ + client, + estimate: estimatedCost, + actionKind: "llm.completion", + actionName: "gpt-4o", + }); +} catch (err) { + if (err instanceof BudgetExceededError) { + console.error("Budget exhausted:", err.message); + return; + } + throw err; +} + +// Stream with cleanup on failure +try { + const stream = await openai.chat.completions.create({ model: "gpt-4o", messages, stream: true }); + // ... process stream ... + await handle.commit(actualCost, metrics); +} catch (err) { + await handle.release("stream_error"); + throw err; +} +``` + +## Express middleware error handling + +Register a global error handler that catches Cycles errors and returns appropriate HTTP responses: + +```typescript +import type { Request, Response, NextFunction } from "express"; +import { CyclesProtocolError, BudgetExceededError } from "runcycles"; + +function cyclesErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) { + if (!(err instanceof CyclesProtocolError)) { + return next(err); + } + + if (err.isBudgetExceeded()) { + const retryAfter = err.retryAfterMs ? Math.ceil(err.retryAfterMs / 1000) : 60; + return res.status(429) + .set("Retry-After", String(retryAfter)) + .json({ error: "budget_exceeded", message: "Budget limit reached." }); + } + + if (err.isDebtOutstanding() || err.isOverdraftLimitExceeded()) { + return res.status(503) + .json({ error: "service_unavailable", message: "Service paused due to budget constraints." }); + } + + return res.status(500) + .json({ error: "internal_error", message: "An unexpected error occurred." }); +} + +// Register after all routes: +app.use(cyclesErrorHandler); +``` + +For per-route handling with the `cyclesGuard` middleware pattern, see the [Express Middleware example](https://github.com/runcycles/cycles-client-typescript/tree/main/examples/express-middleware). + +## Next.js API route error handling + +In Next.js App Router routes, catch errors and return appropriate responses: + +```typescript +import { BudgetExceededError, CyclesProtocolError } from "runcycles"; + +export async function POST(req: Request) { + try { + const result = await handleChat(req); + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + if (err instanceof BudgetExceededError) { + return new Response( + JSON.stringify({ error: "budget_exceeded", message: "Budget limit reached." }), + { status: 402, headers: { "Content-Type": "application/json" } }, + ); + } + if (err instanceof CyclesProtocolError && err.isDebtOutstanding()) { + return new Response( + JSON.stringify({ error: "service_unavailable", message: "Service paused." }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ); + } + throw err; + } +} +``` + +## Graceful degradation with caps + +When the budget system returns `ALLOW_WITH_CAPS`, the decision includes caps that constrain execution. Use these to fall back to cheaper models or limit output: + +```typescript +import { withCycles, getCyclesContext } from "runcycles"; + +const callLlm = withCycles( + { estimate: (prompt: string) => estimateCost(prompt), client, actionKind: "llm.completion", actionName: "gpt-4o" }, + async (prompt: string) => { + const ctx = getCyclesContext(); + + // Respect max tokens cap + let maxTokens = 4096; + if (ctx?.caps?.maxTokens) { + maxTokens = Math.min(maxTokens, ctx.caps.maxTokens); + } + + // Check tool allowlist + const tools = allTools.filter((t) => { + if (!ctx?.caps) return true; + return isToolAllowed(ctx.caps, t.name); + }); + + return openai.chat.completions.create({ + model: "gpt-4o", + max_tokens: maxTokens, + messages: [{ role: "user", content: prompt }], + tools, + }); + }, +); +``` + +## Retry-after with exponential backoff + +When a protocol error includes `retryAfterMs`, use it as the minimum delay before retrying: + +```typescript +import { CyclesProtocolError } from "runcycles"; + +async function withRetry(fn: () => Promise, maxAttempts = 3): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + if (err instanceof CyclesProtocolError && err.isRetryable() && attempt < maxAttempts) { + const baseDelay = err.retryAfterMs ?? 1000 * Math.pow(2, attempt - 1); + await new Promise((resolve) => setTimeout(resolve, baseDelay)); + continue; + } + throw err; + } + } + throw new Error("Unreachable"); +} +``` + +## Distinguishing retryable vs non-retryable errors + +Use `isRetryable()` to check whether an error warrants a retry: + +```typescript +import { CyclesProtocolError } from "runcycles"; + +try { + result = await guardedFunc(); +} catch (err) { + if (err instanceof CyclesProtocolError) { + if (err.isRetryable()) { + // INTERNAL_ERROR, UNKNOWN, or 5xx — safe to retry with backoff + return retryLater(err.retryAfterMs); + } + if (err.isBudgetExceeded()) { + // Budget may free up — retry with backoff or degrade + return fallback(); + } + // RESERVATION_EXPIRED, IDEMPOTENCY_MISMATCH, etc. — do not retry + throw err; + } + throw err; +} +``` + +## Next steps + +- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns across all languages +- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — protocol error code reference +- [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer) — strategies for handling budget constraints +- [Getting Started with the TypeScript Client](/quickstart/getting-started-with-the-typescript-client) — TypeScript client setup +- [Testing with Cycles](/how-to/testing-with-cycles) — testing patterns for Cycles-governed code diff --git a/how-to/testing-with-cycles.md b/how-to/testing-with-cycles.md index 3e6c4c1..b8d8c3e 100644 --- a/how-to/testing-with-cycles.md +++ b/how-to/testing-with-cycles.md @@ -1,6 +1,6 @@ # Testing with Cycles -This guide covers how to test code that uses the `@cycles` decorator (Python) or the `@Cycles` annotation (Java) and the `CyclesClient` interface. +This guide covers how to test code that uses the `@cycles` decorator (Python), the `@Cycles` annotation (Java), or the `withCycles` HOF (TypeScript) and the `CyclesClient` interface. ## Python @@ -374,17 +374,325 @@ void testEstimateExpression() { } ``` +## TypeScript + +### Unit testing withCycles-wrapped functions + +`withCycles` wraps a function with budget governance. In a unit test, you can test the inner function directly without the wrapper: + +```typescript +// The inner function (no budget governance) +async function callLlm(prompt: string): Promise { + return `Response to: ${prompt}`; +} + +// Test the business logic directly +import { describe, it, expect } from "vitest"; + +describe("callLlm", () => { + it("returns a response", async () => { + const result = await callLlm("Hello"); + expect(result).toBe("Response to: Hello"); + }); +}); +``` + +### Mocking CyclesClient with Vitest + +When testing code that uses `CyclesClient` programmatically, mock the client methods: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { CyclesClient } from "runcycles"; +import { CyclesResponse } from "runcycles"; + +describe("processDocument", () => { + it("creates reservation and commits on success", async () => { + const client = { + config: { tenant: "acme" }, + createReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { + reservation_id: "res-123", + decision: "ALLOW", + affected_scopes: ["tenant:acme"], + expires_at_ms: Date.now() + 60000, + }), + ), + commitReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { status: "COMMITTED" }), + ), + releaseReservation: vi.fn(), + extendReservation: vi.fn(), + }; + + const result = await processDocument(client as any, "doc-1", "content"); + + expect(result).toBeDefined(); + expect(client.createReservation).toHaveBeenCalledOnce(); + expect(client.commitReservation).toHaveBeenCalledOnce(); + }); + + it("returns fallback on budget denied", async () => { + const client = { + config: { tenant: "acme" }, + createReservation: vi.fn().mockResolvedValue( + CyclesResponse.httpError( + 409, + "Insufficient remaining balance", + { error: "BUDGET_EXCEEDED", message: "Insufficient remaining balance" }, + ), + ), + commitReservation: vi.fn(), + releaseReservation: vi.fn(), + extendReservation: vi.fn(), + }; + + const result = await processDocument(client as any, "doc-1", "content"); + + expect(result).toBe("Budget exhausted. Please try again later."); + expect(client.commitReservation).not.toHaveBeenCalled(); + }); + + it("releases reservation on processing failure", async () => { + const client = { + config: { tenant: "acme" }, + createReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { + reservation_id: "res-123", + decision: "ALLOW", + affected_scopes: [], + }), + ), + commitReservation: vi.fn(), + releaseReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { status: "RELEASED" }), + ), + extendReservation: vi.fn(), + }; + + await expect( + processDocumentThatFails(client as any, "doc-1", "content"), + ).rejects.toThrow(); + + expect(client.releaseReservation).toHaveBeenCalledOnce(); + }); +}); +``` + +### Mocking fetch for integration tests + +For integration-style tests, mock the global `fetch` to return Cycles API responses: + +```typescript +import { describe, it, expect, vi, afterEach } from "vitest"; +import { CyclesClient, CyclesConfig } from "runcycles"; + +function mockFetchSequence( + responses: Array<{ status: number; body: Record }>, +) { + let callIndex = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + const resp = responses[callIndex] ?? responses[responses.length - 1]; + callIndex++; + return Promise.resolve({ + status: resp.status, + statusText: resp.status >= 400 ? "Error" : "OK", + json: () => Promise.resolve(resp.body), + headers: new Headers(), + }); + }), + ); +} + +describe("full lifecycle", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reserves, executes, and commits", async () => { + mockFetchSequence([ + { + status: 200, + body: { + decision: "ALLOW", + reservation_id: "res-test-001", + affected_scopes: ["tenant:test"], + expires_at_ms: Date.now() + 60000, + }, + }, + { status: 200, body: { status: "COMMITTED" } }, + ]); + + const config = new CyclesConfig({ + baseUrl: "http://localhost:7878", + apiKey: "test-key", + tenant: "test", + }); + const client = new CyclesClient(config); + + const response = await client.createReservation({ + idempotency_key: "test-001", + subject: { tenant: "test" }, + action: { kind: "test", name: "integration" }, + estimate: { unit: "USD_MICROCENTS", amount: 100 }, + }); + + expect(response.isSuccess).toBe(true); + expect(response.getBodyAttribute("reservation_id")).toBe("res-test-001"); + }); +}); +``` + +### Testing error handling + +```typescript +import { BudgetExceededError, CyclesProtocolError } from "runcycles"; + +describe("error handling", () => { + it("creates BudgetExceededError with correct properties", () => { + const err = new BudgetExceededError("Budget exceeded", { + status: 409, + errorCode: "BUDGET_EXCEEDED", + }); + + expect(err.isBudgetExceeded()).toBe(true); + expect(err.isReservationExpired()).toBe(false); + expect(err.status).toBe(409); + expect(err).toBeInstanceOf(CyclesProtocolError); + }); + + it("handles retry-after", () => { + const err = new CyclesProtocolError("Try again later", { + status: 409, + errorCode: "BUDGET_EXCEEDED", + retryAfterMs: 5000, + }); + + expect(err.retryAfterMs).toBe(5000); + }); +}); +``` + +### Testing withCycles with mocked fetch + +Test the full `withCycles` lifecycle by mocking the underlying HTTP calls: + +```typescript +import { withCycles, CyclesClient, CyclesConfig, BudgetExceededError } from "runcycles"; + +describe("withCycles integration", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("executes function within budget lifecycle", async () => { + mockFetchSequence([ + { + status: 200, + body: { + decision: "ALLOW", + reservation_id: "r-1", + affected_scopes: ["tenant:test"], + expires_at_ms: Date.now() + 60000, + }, + }, + { status: 200, body: { status: "COMMITTED" } }, + ]); + + const config = new CyclesConfig({ baseUrl: "http://localhost:7878", apiKey: "key", tenant: "test" }); + const client = new CyclesClient(config); + + const guarded = withCycles( + { estimate: 1000, actionKind: "test", actionName: "unit", client }, + async (input: string) => `Processed: ${input}`, + ); + + const result = await guarded("hello"); + expect(result).toBe("Processed: hello"); + }); + + it("throws BudgetExceededError on deny", async () => { + mockFetchSequence([ + { + status: 409, + body: { error: "BUDGET_EXCEEDED", message: "Insufficient balance" }, + }, + ]); + + const config = new CyclesConfig({ baseUrl: "http://localhost:7878", apiKey: "key", tenant: "test" }); + const client = new CyclesClient(config); + + const guarded = withCycles( + { estimate: 1000, actionKind: "test", actionName: "unit", client }, + async () => "should not run", + ); + + await expect(guarded()).rejects.toThrow(BudgetExceededError); + }); +}); +``` + +### Testing reserveForStream + +Mock the client directly to test streaming handle behavior: + +```typescript +import { reserveForStream } from "runcycles"; +import { CyclesResponse, CyclesConfig } from "runcycles"; + +describe("reserveForStream", () => { + it("creates handle with caps", async () => { + const client = { + config: new CyclesConfig({ baseUrl: "http://localhost", apiKey: "key" }), + createReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { + decision: "ALLOW", + reservation_id: "r-stream-1", + affected_scopes: ["tenant:test"], + caps: { max_tokens: 4096 }, + }), + ), + commitReservation: vi.fn().mockResolvedValue( + CyclesResponse.success(200, { status: "COMMITTED" }), + ), + releaseReservation: vi.fn(), + extendReservation: vi.fn(), + }; + + const handle = await reserveForStream({ + client: client as any, + estimate: 5000, + unit: "USD_MICROCENTS", + actionKind: "llm.completion", + actionName: "gpt-4o", + tenant: "test", + }); + + expect(handle.reservationId).toBe("r-stream-1"); + expect(handle.caps).toEqual({ maxTokens: 4096 }); + + // Commit and verify + await handle.commit(3000, { tokensInput: 100, tokensOutput: 200 }); + expect(client.commitReservation).toHaveBeenCalledOnce(); + }); +}); +``` + ## Tips -- **Unit tests**: test business logic without the decorator/annotation — it has no effect when bypassed -- **Mock CyclesClient**: use Python `MagicMock` or Java `@MockBean` to avoid needing a real server +- **Unit tests**: test business logic without the decorator/annotation/HOF — it has no effect when bypassed +- **Mock CyclesClient**: use Python `MagicMock`, Java `@MockBean`, or TypeScript `vi.fn()` to avoid needing a real server - **Test both ALLOW and DENY paths**: ensure your code handles budget denial gracefully - **Test error paths**: verify release is called when functions/methods throw -- **Use HTTP mocking for integration tests**: `pytest-httpx` for Python, Testcontainers for Java +- **Use HTTP mocking for integration tests**: `pytest-httpx` for Python, Testcontainers for Java, `vi.stubGlobal("fetch")` for TypeScript - **Python-specific**: use `pytest-httpx` for sync and `respx` for async HTTP mocking +- **TypeScript-specific**: mock `fetch` globally with Vitest's `vi.stubGlobal()` or use `msw` (Mock Service Worker) for more realistic HTTP mocking ## Next steps +- [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript) — TypeScript exception handling patterns - [Error Handling in Python](/how-to/error-handling-patterns-in-python) — Python exception handling patterns - [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns - [Using the Client Programmatically](/how-to/using-the-cycles-client-programmatically) — direct client usage diff --git a/how-to/using-the-cycles-client-programmatically.md b/how-to/using-the-cycles-client-programmatically.md index f802e35..44c900a 100644 --- a/how-to/using-the-cycles-client-programmatically.md +++ b/how-to/using-the-cycles-client-programmatically.md @@ -2,7 +2,7 @@ The decorator / annotation handles most use cases automatically. But sometimes you need direct control — building requests manually, managing the lifecycle yourself, or calling endpoints that the decorator does not cover. -Both the Python `CyclesClient` and the Java `CyclesClient` interface provide programmatic access to every Cycles protocol endpoint. +The Python `CyclesClient`, the Java `CyclesClient` interface, and the TypeScript `CyclesClient` class all provide programmatic access to every Cycles protocol endpoint. ## Getting the client @@ -43,6 +43,27 @@ public class BudgetService { } ``` +### TypeScript + +```typescript +import { CyclesClient, CyclesConfig } from "runcycles"; + +const config = new CyclesConfig({ + baseUrl: "http://localhost:7878", + apiKey: "cyc_live_...", + tenant: "acme-corp", +}); + +const client = new CyclesClient(config); +``` + +Or from environment variables: + +```typescript +const config = CyclesConfig.fromEnv(); // reads CYCLES_BASE_URL, CYCLES_API_KEY, etc. +const client = new CyclesClient(config); +``` + ## Creating a reservation ### Python @@ -107,6 +128,33 @@ String decision = (String) body.get("decision"); // Proceed with work... ``` +### TypeScript + +```typescript +import { CyclesClient, CyclesConfig, Unit } from "runcycles"; + +const response = await client.createReservation({ + idempotency_key: "req-abc-123", + subject: { tenant: "acme", workspace: "production", app: "chatbot" }, + action: { kind: "llm.completion", name: "gpt-4o" }, + estimate: { unit: Unit.USD_MICROCENTS, amount: 5000 }, + ttl_ms: 60_000, + overage_policy: "REJECT", +}); + +if (!response.isSuccess) { + throw new Error(`Reservation failed: ${response.errorMessage}`); +} + +const reservationId = response.getBodyAttribute("reservation_id") as string; +const decision = response.getBodyAttribute("decision") as string; + +// For non-dry-run reservations, insufficient budget returns 409 (not decision=DENY). +// decision=DENY in a 2xx response only occurs when dry_run=true. + +// Proceed with work... +``` + ## Committing actual usage ### Python @@ -147,6 +195,22 @@ CyclesResponse> commitResponse = cyclesClient.commitReservation(reservationId, commitRequest); ``` +### TypeScript + +```typescript +await client.commitReservation(reservationId, { + idempotency_key: "commit-abc-123", + actual: { unit: Unit.USD_MICROCENTS, amount: 3200 }, + metrics: { + tokens_input: 150, + tokens_output: 80, + latency_ms: 320, + model_version: "gpt-4o-2024-08-06", + }, + metadata: { request_id: "req-abc-123" }, +}); +``` + ## Releasing a reservation If work is cancelled or fails before producing any usage: @@ -173,6 +237,15 @@ ReleaseRequest releaseRequest = ReleaseRequest.builder() cyclesClient.releaseReservation(reservationId, releaseRequest); ``` +### TypeScript + +```typescript +await client.releaseReservation(reservationId, { + idempotency_key: "release-abc-123", + reason: "Task cancelled by user", +}); +``` + ## Full lifecycle example ### Python @@ -304,6 +377,64 @@ public class DocumentProcessor { } ``` +### TypeScript + +```typescript +import { CyclesClient, CyclesConfig, Unit } from "runcycles"; + +const config = new CyclesConfig({ + baseUrl: "http://localhost:7878", + apiKey: "cyc_live_...", + tenant: "acme", +}); + +async function processDocument(docId: string, content: string): Promise { + const idempotencyKey = `doc-${docId}`; + const estimatedTokens = Math.ceil(content.length / 4); + const client = new CyclesClient(config); + + // 1. Reserve + const response = await client.createReservation({ + idempotency_key: idempotencyKey, + subject: { tenant: "acme", workspace: "production", app: "doc-processor" }, + action: { kind: "llm.completion", name: "gpt-4o" }, + estimate: { unit: Unit.USD_MICROCENTS, amount: estimatedTokens * 10 }, + ttl_ms: 120_000, + overage_policy: "ALLOW_IF_AVAILABLE", + }); + + if (!response.isSuccess) { + throw new Error(`Reservation failed: ${response.errorMessage}`); + } + + const reservationId = response.getBodyAttribute("reservation_id") as string; + + // 2. Execute + try { + const result = await callLlm(content); + + // 3. Commit + const actualTokens = countTokens(result); + await client.commitReservation(reservationId, { + idempotency_key: `commit-${idempotencyKey}`, + actual: { unit: Unit.USD_MICROCENTS, amount: actualTokens * 10 }, + metrics: { + tokens_input: estimatedTokens, + tokens_output: actualTokens, + }, + }); + return result; + } catch (err) { + // 4. Release on failure + await client.releaseReservation(reservationId, { + idempotency_key: `release-${idempotencyKey}`, + reason: "Processing failed", + }); + throw err; + } +} +``` + ## Preflight decision check Check budget availability without creating a reservation. @@ -346,6 +477,22 @@ if ("DENY".equals(decision)) { } ``` +### TypeScript + +```typescript +const decisionResponse = await client.decide({ + idempotency_key: "decide-001", + subject: { tenant: "acme", workspace: "production" }, + action: { kind: "llm.completion", name: "gpt-4o" }, + estimate: { unit: Unit.USD_MICROCENTS, amount: 50_000 }, +}); + +const decision = decisionResponse.getBodyAttribute("decision") as string; +if (decision === "DENY") { + console.log("Budget low — show warning in UI"); +} +``` + ## Querying balances ### Python @@ -379,6 +526,18 @@ for (Map balance : balances) { } ``` +### TypeScript + +```typescript +const balanceResponse = await client.getBalances({ tenant: "acme", workspace: "production" }); +if (balanceResponse.isSuccess) { + const balances = balanceResponse.getBodyAttribute("balances") as Array>; + for (const balance of balances ?? []) { + console.log(`Scope: ${balance.scope}, remaining: ${JSON.stringify(balance.remaining)}`); + } +} +``` + ## Listing reservations ### Python @@ -403,6 +562,22 @@ CyclesResponse> listResponse = cyclesClient.listReservations(params); ``` +### TypeScript + +```typescript +const listResponse = await client.listReservations({ + tenant: "acme", + status: "ACTIVE", + limit: "20", +}); +if (listResponse.isSuccess) { + const reservations = listResponse.getBodyAttribute("reservations") as Array>; + for (const r of reservations ?? []) { + console.log(`ID: ${r.reservation_id}, status: ${r.status}`); + } +} +``` + ## Recording events (direct debit) For post-hoc accounting without a reservation. @@ -436,6 +611,17 @@ EventCreateRequest event = EventCreateRequest.builder() cyclesClient.createEvent(event); ``` +### TypeScript + +```typescript +await client.createEvent({ + idempotency_key: "evt-001", + subject: { tenant: "acme", workspace: "production" }, + action: { kind: "search.api", name: "google-search" }, + actual: { unit: Unit.USD_MICROCENTS, amount: 1200 }, +}); +``` + ## CyclesResponse ### Python @@ -470,6 +656,24 @@ response.getBody(); // parsed JSON body as Map response.getErrorMessage(); // error message (if error) ``` +### TypeScript + +All client methods return `CyclesResponse`: + +```typescript +const response = await client.createReservation(request); + +response.isSuccess; // true if HTTP 2xx +response.isServerError; // true if HTTP 5xx +response.isTransportError; // true if connection failed +response.status; // HTTP status code +response.body; // Parsed JSON body (wire format) +response.errorMessage; // Error message (if error) +response.requestId; // X-Request-Id header +response.rateLimitRemaining; // X-RateLimit-Remaining (number or undefined) +response.cyclesTenant; // X-Cycles-Tenant header +``` + ## Async support (Python) The Python client provides `AsyncCyclesClient` for asyncio-based applications: @@ -489,7 +693,7 @@ async with AsyncCyclesClient(config) as client: | Use case | Approach | |---|---| -| Wrapping a single method call in a budget lifecycle | `@cycles` decorator / `@Cycles` annotation | +| Wrapping a single method call in a budget lifecycle | `@cycles` decorator / `@Cycles` annotation / `withCycles` HOF | | Managing multiple reservations in a workflow | Programmatic `CyclesClient` | | Querying balances or listing reservations | Programmatic `CyclesClient` | | Preflight decisions for UI routing | Programmatic `CyclesClient` | @@ -498,8 +702,10 @@ async with AsyncCyclesClient(config) as client: ## Next steps +- [Getting Started with the TypeScript Client](/quickstart/getting-started-with-the-typescript-client) — TypeScript HOF and streaming adapter setup - [Getting Started with the Python Client](/quickstart/getting-started-with-the-python-client) — Python decorator and client setup - [Getting Started with the Spring Boot Starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — Java annotation-based approach - [API Reference](/api/) — interactive endpoint documentation +- [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript) — TypeScript exception hierarchy and patterns - [Error Handling in Python](/how-to/error-handling-patterns-in-python) — Python exception hierarchy and patterns -- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — Java error handling patterns +- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns diff --git a/quickstart/getting-started-with-the-typescript-client.md b/quickstart/getting-started-with-the-typescript-client.md index f73d6c6..c56b553 100644 --- a/quickstart/getting-started-with-the-typescript-client.md +++ b/quickstart/getting-started-with-the-typescript-client.md @@ -496,6 +496,8 @@ For each `withCycles`-guarded function call: ## Next steps - [TypeScript Client Configuration Reference](/configuration/typescript-client-configuration-reference) — all config options and environment variables -- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns -- [API Reference](/api/) — interactive endpoint documentation +- [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript) — exception hierarchy, Express/Next.js patterns +- [Testing with Cycles](/how-to/testing-with-cycles) — unit and integration testing patterns - [Using the Client Programmatically](/how-to/using-the-cycles-client-programmatically) — programmatic client reference +- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns across all languages +- [API Reference](/api/) — interactive endpoint documentation From 52d885380e56756e7f7b4021dfb71ed7f7878d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 12:09:33 +0000 Subject: [PATCH 2/2] Fix audit issues: add sidebar entry and fix missing isToolAllowed import - Add 'Error Handling in TypeScript' to VitePress sidebar config - Add missing isToolAllowed import in TypeScript error handling guide https://claude.ai/code/session_01AioSM8RvRKCXgRsV6m75Mm --- .vitepress/config.ts | 1 + how-to/error-handling-patterns-in-typescript.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 5a8297b..514ef30 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -91,6 +91,7 @@ export default defineConfig({ { text: 'Degradation Paths', link: '/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer' }, { text: 'Shadow Mode Rollout', link: '/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production' }, { text: 'Error Handling Patterns', link: '/how-to/error-handling-patterns-in-cycles-client-code' }, + { text: 'Error Handling in TypeScript', link: '/how-to/error-handling-patterns-in-typescript' }, { text: 'Error Handling in Python', link: '/how-to/error-handling-patterns-in-python' }, { text: 'Testing with Cycles', link: '/how-to/testing-with-cycles' }, ] diff --git a/how-to/error-handling-patterns-in-typescript.md b/how-to/error-handling-patterns-in-typescript.md index bfaec73..df95b10 100644 --- a/how-to/error-handling-patterns-in-typescript.md +++ b/how-to/error-handling-patterns-in-typescript.md @@ -196,7 +196,7 @@ export async function POST(req: Request) { When the budget system returns `ALLOW_WITH_CAPS`, the decision includes caps that constrain execution. Use these to fall back to cheaper models or limit output: ```typescript -import { withCycles, getCyclesContext } from "runcycles"; +import { withCycles, getCyclesContext, isToolAllowed } from "runcycles"; const callLlm = withCycles( { estimate: (prompt: string) => estimateCost(prompt), client, actionKind: "llm.completion", actionName: "gpt-4o" },