feat(sdk): add production HTTP controls and fix review findings (#3137)

* ctx7-2663: address SDK review findings

* ctx7-2663: tighten SDK test architecture

* ctx7-2663: address SDK type review

* feat(sdk): add production HTTP controls

* refactor(sdk): align HTTP conventions with redis-js

* refactor(sdk): decompose HTTP transport

* refactor(sdk): tighten retry API

* ctx7-2663: address latest SDK review
This commit is contained in:
Fahreddin Özcan
2026-09-04 16:43:56 +03:00
committed by GitHub
parent a37d30cf14
commit 4eff2b90b9
28 changed files with 1566 additions and 523 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@upstash/context7-sdk": minor
---
Fix response type inference for runtime-selected formats, honor disabled retries, and separate deterministic SDK tests from live API integration tests. Calls that forward options whose response format is selected at runtime now correctly return an array-or-string union and may require result narrowing.
Add production HTTP controls while keeping API-key authentication required: client and per-request timeouts, abort signals, configurable transient HTTP retries, native fetch cache settings, custom fetch/base URL/header/keepalive support, URL validation, response metadata hooks, and structured `Context7Error` fields for status, code, request ID, rate limits, retryability, malformed JSON, and cause.
Requests now time out after 30 seconds by default. Set `timeout: false` on the client or an individual request to disable the timeout.
+6
View File
@@ -82,3 +82,9 @@ jobs:
run: pnpm test
env:
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}
- name: SDK Integration Test
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: pnpm --filter @upstash/context7-sdk test:integration
env:
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}
+9
View File
@@ -26,6 +26,15 @@ Retrieve documentation context for a specific library. Returns documentation as
Default: `"json"`
</ParamField>
<ParamField path="signal" type="AbortSignal">
Abort signal for cancelling this request.
</ParamField>
<ParamField path="timeout" type="number | false">
Per-request timeout in milliseconds. Use `false` to disable the client timeout.
</ParamField>
<ParamField path="cache" type="CacheSetting">
Native fetch cache mode. Use `false` to omit the cache option.
</ParamField>
</Expandable>
</ParamField>
+17
View File
@@ -17,6 +17,23 @@ Search across available libraries. Returns an array of matching libraries with m
The library name to search for
</ParamField>
<ParamField path="options" type="SearchLibraryOptions">
<Expandable title="properties">
<ParamField path="type" type="'json' | 'txt'">
Format of the response. Defaults to `json`.
</ParamField>
<ParamField path="signal" type="AbortSignal">
Abort signal for cancelling this request.
</ParamField>
<ParamField path="timeout" type="number | false">
Per-request timeout in milliseconds. Use `false` to disable the client timeout.
</ParamField>
<ParamField path="cache" type="CacheSetting">
Native fetch cache mode. Use `false` to omit the cache option.
</ParamField>
</Expandable>
</ParamField>
## Response
Returns `Library[]` - an array of library objects.
+45 -1
View File
@@ -79,6 +79,42 @@ const client = new Context7({
`process.env.CONTEXT7_API_KEY`
</Note>
#### Production HTTP configuration
The SDK applies a 30-second request timeout and retries transient network failures, `408`, `425`,
`429`, and `5xx` responses. Only `GET` requests are retried; mutating requests remain
single-attempt.
```typescript
const client = new Context7({
apiKey: "YOUR_API_KEY",
timeout: 10_000,
retry: {
retries: 3,
backoff: (attempt) => 100 * 2 ** attempt,
},
onResponse: ({ status, requestId, rateLimit, attempt }) => {
console.log({ status, requestId, rateLimit, attempt });
},
});
```
You can also configure `baseUrl`, additional `headers`, `keepAlive`, the native fetch `cache` mode,
a client-wide abort `signal`, or a custom `fetch` implementation. The SDK always sets
`Authorization` from the configured API key; additional headers cannot override it.
Following the same convention as `@upstash/redis`, a signal factory can provide a fresh timeout
signal for each request:
```typescript
const client = new Context7({
apiKey: "YOUR_API_KEY",
signal: () => AbortSignal.timeout(10_000),
});
```
Set `retry: false` to make exactly one request or `timeout: false` to disable the default timeout.
## Quick Start Example
```typescript
@@ -104,6 +140,7 @@ console.log(docs[0].title, docs[0].content);
// Get documentation context as plain text
const context = await client.getContext("How do I use hooks?", "/facebook/react", {
type: "txt",
timeout: 5_000,
});
console.log(context);
```
@@ -121,7 +158,14 @@ try {
const context = await client.getContext("query", "/invalid/library");
} catch (error) {
if (error instanceof Context7Error) {
console.error("Context7 API Error:", error.message);
console.error("Context7 API Error:", {
message: error.message,
code: error.code,
status: error.status,
requestId: error.requestId,
rateLimit: error.rateLimit,
retryable: error.retryable,
});
} else {
console.error("Unexpected error:", error);
}
+63 -9
View File
@@ -40,10 +40,7 @@ const client = new Context7({
});
// Search for libraries
const libraries = await client.searchLibrary(
"I need to build a UI with components",
"react"
);
const libraries = await client.searchLibrary("I need to build a UI with components", "react");
console.log(libraries[0].id); // "/facebook/react"
// Get documentation as JSON array (default)
@@ -51,11 +48,7 @@ const docs = await client.getContext("How do I use hooks?", "/facebook/react");
console.log(docs[0].title, docs[0].content);
// Get documentation context as plain text
const context = await client.getContext(
"How do I use hooks?",
"/facebook/react",
{ type: "txt" }
);
const context = await client.getContext("How do I use hooks?", "/facebook/react", { type: "txt" });
console.log(context);
```
@@ -75,6 +68,61 @@ Then initialize without options:
const client = new Context7();
```
### Production HTTP options
Requests time out after 30 seconds and retry transient network errors, `408`, `425`, `429`,
and `5xx` responses by default. You can configure those defaults for the client and override
timeout, cancellation, and native fetch caching per request:
```ts
import { Context7, Context7Error } from "@upstash/context7-sdk";
const client = new Context7({
apiKey: process.env.CONTEXT7_API_KEY,
timeout: 10_000,
retry: {
retries: 3,
backoff: (attempt) => 100 * 2 ** attempt,
},
onResponse: ({ status, requestId, rateLimit, attempt }) => {
console.log({ status, requestId, rateLimit, attempt });
},
});
const controller = new AbortController();
try {
const docs = await client.getContext("How do I use hooks?", "/facebook/react", {
signal: controller.signal,
timeout: 5_000,
cache: "no-store",
});
console.log(docs);
} catch (error) {
if (error instanceof Context7Error) {
console.error(error.code, error.status, error.requestId, error.rateLimit);
}
}
```
The client also accepts `baseUrl`, `headers`, `keepAlive`, and a custom `fetch` implementation for
proxies, instrumentation, tests, and runtimes that do not expose a global `fetch`. The configured
API key always controls the `Authorization` header.
As in `@upstash/redis`, you can express a timeout with a fresh signal for every request:
```ts
const client = new Context7({
apiKey: process.env.CONTEXT7_API_KEY,
signal: () => AbortSignal.timeout(10_000),
});
```
Set `retry: false` to make exactly one request, `timeout: false` to disable the request timeout,
or `cache: false` to omit the native fetch cache option.
Only `GET` requests are retried. Mutating requests remain single-attempt.
## Docs
See the [documentation](https://context7.com/docs/sdks/ts/getting-started) for details.
@@ -87,6 +135,12 @@ See the [documentation](https://context7.com/docs/sdks/ts/getting-started) for d
pnpm test
```
Run the live API integration tests separately with a configured API key:
```sh
pnpm test:integration
```
### Building
```sh
+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"dev": "tsup --watch",
+120
View File
@@ -0,0 +1,120 @@
import { describe, test, expect } from "vitest";
import { Context7 } from "./client";
describe("Context7 Client integration", () => {
const apiKey = process.env.CONTEXT7_API_KEY!;
describe("searchLibrary", () => {
const client = new Context7({ apiKey });
test("should search for libraries and return array directly", async () => {
const result = await client.searchLibrary("I need to build a UI", "react");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should return Library objects with all fields", async () => {
const result = await client.searchLibrary("I want to use TypeScript", "typescript");
expect(result.length).toBeGreaterThan(0);
const library = result[0];
expect(library).toHaveProperty("id");
expect(library).toHaveProperty("name");
expect(library).toHaveProperty("description");
expect(library).toHaveProperty("totalSnippets");
expect(library).toHaveProperty("trustScore");
expect(library).toHaveProperty("benchmarkScore");
});
test("should search with different queries", async () => {
const queries = ["vue", "express", "next"];
for (const query of queries) {
const result = await client.searchLibrary(`I want to use ${query}`, query);
expect(result.length).toBeGreaterThan(0);
}
}, 15000);
});
describe("getContext - JSON format (default)", () => {
const client = new Context7({ apiKey });
test("should get context as Documentation array (default)", async () => {
const result = await client.getContext("How to use hooks", "/react/react");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should get context with explicit json type", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "json",
});
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should have correct Documentation structure", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "json",
});
expect(result.length).toBeGreaterThan(0);
const doc = result[0];
expect(doc).toHaveProperty("title");
expect(doc).toHaveProperty("content");
expect(doc).toHaveProperty("source");
expect(typeof doc.title).toBe("string");
expect(typeof doc.content).toBe("string");
expect(typeof doc.source).toBe("string");
});
});
describe("getContext - text format", () => {
const client = new Context7({ apiKey });
test("should get context as text string with type: txt", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "txt",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
});
describe("getContext - different libraries", () => {
const client = new Context7({ apiKey });
test("should get context for Vue", async () => {
const result = await client.getContext("How to create components", "/vuejs/core");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should get context for Express", async () => {
const result = await client.getContext("How to create routes", "/expressjs/express");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
});
describe("live error handling", () => {
const client = new Context7({ apiKey });
test("should handle invalid library ID gracefully", async () => {
await expect(client.getContext("test query", "/nonexistent/library")).rejects.toThrow();
});
});
});
+83 -152
View File
@@ -1,179 +1,110 @@
import { describe, test, expect } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Context7 } from "./client";
import { Context7Error } from "@error";
describe("Context7 Client", () => {
const apiKey = process.env.CONTEXT7_API_KEY || process.env.API_KEY!;
describe("constructor", () => {
test("should create client with API key", () => {
const client = new Context7({ apiKey });
expect(client).toBeDefined();
});
test("should create client from environment variables", () => {
const client = new Context7();
expect(client).toBeDefined();
});
test("should throw error when API key is missing", () => {
const originalEnv = process.env.CONTEXT7_API_KEY;
const originalApiKey = process.env.API_KEY;
delete process.env.CONTEXT7_API_KEY;
delete process.env.API_KEY;
expect(() => new Context7({ apiKey: "" })).toThrow(Context7Error);
expect(() => new Context7({})).toThrow(Context7Error);
expect(() => new Context7()).toThrow("API key is required");
if (originalEnv) process.env.CONTEXT7_API_KEY = originalEnv;
if (originalApiKey) process.env.API_KEY = originalApiKey;
});
test("should prefer config API key over environment variable", () => {
const customApiKey = "ctx7sk-custom-key";
const client = new Context7({ apiKey: customApiKey });
expect(client).toBeDefined();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("searchLibrary", () => {
const client = new Context7({ apiKey });
test("should search for libraries and return array directly", async () => {
const result = await client.searchLibrary("I need to build a UI", "react");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should return Library objects with all fields", async () => {
const result = await client.searchLibrary("I want to use TypeScript", "typescript");
expect(result.length).toBeGreaterThan(0);
const library = result[0];
expect(library).toHaveProperty("id");
expect(library).toHaveProperty("name");
expect(library).toHaveProperty("description");
expect(library).toHaveProperty("totalSnippets");
expect(library).toHaveProperty("trustScore");
expect(library).toHaveProperty("benchmarkScore");
});
test("should search with different queries", async () => {
const queries = ["vue", "express", "next"];
for (const query of queries) {
const result = await client.searchLibrary(`I want to use ${query}`, query);
expect(result.length).toBeGreaterThan(0);
}
}, 15000);
test("creates a client with an explicit API key", () => {
expect(new Context7({ apiKey: "ctx7sk-config" })).toBeDefined();
});
describe("getContext - JSON format (default)", () => {
const client = new Context7({ apiKey });
test("creates a client from the environment", () => {
vi.stubEnv("CONTEXT7_API_KEY", "ctx7sk-environment");
test("should get context as Documentation array (default)", async () => {
const result = await client.getContext("How to use hooks", "/react/react");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should get context with explicit json type", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "json",
});
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should have correct Documentation structure", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "json",
});
expect(result.length).toBeGreaterThan(0);
const doc = result[0];
expect(doc).toHaveProperty("title");
expect(doc).toHaveProperty("content");
expect(doc).toHaveProperty("source");
expect(typeof doc.title).toBe("string");
expect(typeof doc.content).toBe("string");
expect(typeof doc.source).toBe("string");
});
expect(new Context7()).toBeDefined();
});
describe("getContext - text format", () => {
const client = new Context7({ apiKey });
test("requires an API key", () => {
vi.stubEnv("CONTEXT7_API_KEY", "");
test("should get context as text string with type: txt", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "txt",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
expect(() => new Context7({ apiKey: "" })).toThrow(Context7Error);
expect(() => new Context7()).toThrow("API key is required");
});
describe("getContext - different libraries", () => {
const client = new Context7({ apiKey });
test("prefers the configured API key over the environment", () => {
vi.stubEnv("CONTEXT7_API_KEY", "invalid-environment-key");
const warn = vi.spyOn(console, "warn");
test("should get context for Vue", async () => {
const result = await client.getContext("How to create components", "/vuejs/core");
new Context7({ apiKey: "ctx7sk-config" });
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
test("should get context for Express", async () => {
const result = await client.getContext("How to create routes", "/expressjs/express");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
expect(warn).not.toHaveBeenCalled();
});
describe("error handling", () => {
const client = new Context7({ apiKey });
test("works in runtimes without process when an API key is configured", () => {
vi.stubGlobal("process", undefined);
test("should handle invalid library ID gracefully", async () => {
await expect(client.getContext("test query", "/nonexistent/library")).rejects.toThrow();
});
test("should handle invalid search query", async () => {
await expect(client.searchLibrary("", "")).rejects.toThrow(Context7Error);
});
expect(new Context7({ apiKey: "ctx7sk-config" })).toBeDefined();
expect(() => new Context7()).toThrow(Context7Error);
});
describe("type inference", () => {
const client = new Context7({ apiKey });
test("should infer Documentation[] for default (json) format", async () => {
const result = await client.getContext("How to use hooks", "/react/react");
expect(Array.isArray(result)).toBe(true);
expect(result[0]).toHaveProperty("title");
expect(result[0]).toHaveProperty("content");
expect(result[0]).toHaveProperty("source");
test("forwards transport configuration and protects the authorization header", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ results: [] }), {
headers: { "content-type": "application/json" },
})
);
const onResponse = vi.fn();
const client = new Context7({
apiKey: "ctx7sk-config",
baseUrl: "https://proxy.example.com/context7/",
cache: "force-cache",
retry: false,
timeout: false,
keepAlive: false,
fetch: fetchMock,
headers: {
authorization: "Bearer should-not-win",
"X-Application": "test-suite",
},
onResponse,
});
test("should infer string type for txt format", async () => {
const result = await client.getContext("How to use hooks", "/react/react", {
type: "txt",
});
await client.searchLibrary("state management", "react");
expect(typeof result).toBe("string");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(
"https://proxy.example.com/context7/v2/libs/search?query=state+management&libraryName=react"
);
expect(init).toMatchObject({
cache: "force-cache",
keepalive: false,
headers: {
Authorization: "Bearer ctx7sk-config",
"Content-Type": "application/json",
"X-Application": "test-suite",
},
});
expect(onResponse).toHaveBeenCalledWith({ status: 200, attempt: 0 });
});
test("forwards per-request cache, timeout, and abort options", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ results: [] }), {
headers: { "content-type": "application/json" },
})
);
const controller = new AbortController();
const client = new Context7({
apiKey: "ctx7sk-config",
cache: "no-store",
timeout: 30_000,
fetch: fetchMock,
});
await client.searchLibrary("state management", "react", {
cache: "reload",
timeout: false,
signal: controller.signal,
});
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(init.cache).toBe("reload");
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
+36 -16
View File
@@ -13,13 +13,20 @@ const DEFAULT_BASE_URL = "https://context7.com/api";
const API_KEY_PREFIX = "ctx7sk";
export type * from "@commands/types";
export type {
CacheSetting,
Context7Fetch,
Context7ResponseMetadata,
RateLimitMetadata,
RetryConfig,
} from "@http";
export * from "@error";
export class Context7 {
private httpClient: HttpClient;
private readonly httpClient: HttpClient;
constructor(config: Context7Config = {}) {
const apiKey = config.apiKey || process.env.CONTEXT7_API_KEY;
const apiKey = config.apiKey || getEnvironmentApiKey();
if (!apiKey) {
throw new Context7Error(
@@ -32,15 +39,18 @@ export class Context7 {
}
this.httpClient = new HttpClient({
baseUrl: DEFAULT_BASE_URL,
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
headers: {
...withoutAuthorizationHeader(config.headers),
Authorization: `Bearer ${apiKey}`,
},
retry: {
retries: 5,
backoff: (retryCount) => Math.exp(retryCount) * 50,
},
cache: "no-store",
retry: config.retry,
cache: config.cache ?? "no-store",
timeout: config.timeout,
signal: config.signal,
keepAlive: config.keepAlive,
fetch: config.fetch,
onResponse: config.onResponse,
});
}
@@ -50,7 +60,7 @@ export class Context7 {
async searchLibrary(
query: string,
libraryName: string,
options: SearchLibraryOptions & { type: "json" }
options?: SearchLibraryOptions & { type?: "json" }
): Promise<Library[]>;
/**
@@ -63,13 +73,13 @@ export class Context7 {
): Promise<string>;
/**
* Search for libraries matching the given query (defaults to JSON)
* Search for libraries with options whose response type is determined at runtime
*/
async searchLibrary(
query: string,
libraryName: string,
options?: SearchLibraryOptions
): Promise<Library[]>;
): Promise<Library[] | string>;
/**
* Search for libraries matching the given query
@@ -84,7 +94,7 @@ export class Context7 {
options?: SearchLibraryOptions
): Promise<Library[] | string> {
const command = new SearchLibraryCommand(query, libraryName, options);
return await command.exec(this.httpClient);
return command.exec(this.httpClient);
}
/**
@@ -93,7 +103,7 @@ export class Context7 {
async getContext(
query: string,
libraryId: string,
options: GetContextOptions & { type: "json" }
options?: GetContextOptions & { type?: "json" }
): Promise<Documentation[]>;
/**
@@ -106,13 +116,13 @@ export class Context7 {
): Promise<string>;
/**
* Get documentation context for a library (defaults to JSON)
* Get documentation context with options whose response type is determined at runtime
*/
async getContext(
query: string,
libraryId: string,
options?: GetContextOptions
): Promise<Documentation[]>;
): Promise<Documentation[] | string>;
/**
* Get documentation context for a library
@@ -127,6 +137,16 @@ export class Context7 {
options?: GetContextOptions
): Promise<Documentation[] | string> {
const command = new GetContextCommand(query, libraryId, options);
return await command.exec(this.httpClient);
return command.exec(this.httpClient);
}
}
function getEnvironmentApiKey(): string | undefined {
return typeof process === "undefined" ? undefined : process.env?.CONTEXT7_API_KEY;
}
function withoutAuthorizationHeader(headers?: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() !== "authorization")
);
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expectTypeOf, test } from "vitest";
import {
Context7,
type Documentation,
type GetContextOptions,
type Library,
type SearchLibraryOptions,
} from "./client";
function searchWithOptions(client: Context7, options: SearchLibraryOptions) {
return client.searchLibrary("query", "react", options);
}
function getContextWithOptions(client: Context7, options: GetContextOptions) {
return client.getContext("query", "/react/react", options);
}
function searchWithOptionalOptions(client: Context7, options: SearchLibraryOptions | undefined) {
return client.searchLibrary("query", "react", options);
}
function getContextWithOptionalOptions(client: Context7, options: GetContextOptions | undefined) {
return client.getContext("query", "/react/react", options);
}
function searchWithDefaultOptions(client: Context7) {
return client.searchLibrary("query", "react", {});
}
function getContextWithDefaultOptions(client: Context7) {
return client.getContext("query", "/react/react", {});
}
describe("Context7 Client types", () => {
test("returns a union when the search response type is determined at runtime", () => {
expectTypeOf(searchWithOptions).returns.toEqualTypeOf<Promise<Library[] | string>>();
});
test("returns a union when the context response type is determined at runtime", () => {
expectTypeOf(getContextWithOptions).returns.toEqualTypeOf<Promise<Documentation[] | string>>();
});
test("accepts optional response options", () => {
expectTypeOf(searchWithOptionalOptions).returns.toEqualTypeOf<Promise<Library[] | string>>();
expectTypeOf(getContextWithOptionalOptions).returns.toEqualTypeOf<
Promise<Documentation[] | string>
>();
});
test("preserves JSON return types for empty options", () => {
expectTypeOf(searchWithDefaultOptions).returns.toEqualTypeOf<Promise<Library[]>>();
expectTypeOf(getContextWithDefaultOptions).returns.toEqualTypeOf<Promise<Documentation[]>>();
});
});
+12 -14
View File
@@ -1,20 +1,17 @@
import type { Requester } from "@http";
import { Context7Error } from "@error";
import type { Context7Request, Requester } from "@http";
export const _ENDPOINTS = ["v2/libs/search", "v2/context"];
export type EndpointVariants = (typeof _ENDPOINTS)[number];
export interface CommandRequest {
method?: "GET" | "POST";
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
}
export type CommandRequest = Omit<Context7Request, "path">;
export class Command<TResult> {
public readonly request: CommandRequest;
public readonly endpoint: EndpointVariants;
constructor(request: CommandRequest, endpoint: EndpointVariants | string) {
constructor(request: CommandRequest, endpoint: EndpointVariants) {
this.request = request;
this.endpoint = endpoint;
}
@@ -23,15 +20,16 @@ export class Command<TResult> {
* Execute the command using a client.
*/
public async exec(client: Requester): Promise<TResult> {
const { result } = await client.request<TResult>({
method: this.request.method || "POST",
path: [this.endpoint],
query: this.request.query,
body: this.request.body,
});
return this.requestResult<TResult>(client);
}
protected async requestResult<T>(client: Requester): Promise<T> {
const { result } = await client.request<T>({ ...this.request, path: [this.endpoint] });
if (result === undefined) {
throw new TypeError("Request did not return a result");
throw new Context7Error("Request did not return a result", {
code: "invalid_response",
});
}
return result;
@@ -1,67 +1,52 @@
import { describe, test, expect } from "vitest";
import { GetContextCommand } from "./index";
import { newHttpClient } from "../../utils/test-utils";
import { Context7 } from "../../client";
import type { Documentation } from "@commands/types";
const httpClient = newHttpClient();
import { requesterWith } from "@utils/test-utils";
describe("GetContextCommand", () => {
test("should get library context as JSON (default)", async () => {
test("maps code and information snippets to documentation", async () => {
const command = new GetContextCommand("How to use hooks", "/react/react");
const result = await command.exec(httpClient);
const result = await command.exec(
requesterWith({
codeSnippets: [
{
codeTitle: "State hook",
codeDescription: "Store component state.",
codeLanguage: "tsx",
codeList: [{ language: "tsx", code: "const [value] = useState(0);" }],
codeId: "hooks/use-state",
},
],
infoSnippets: [
{
breadcrumb: "Hooks > State",
content: "State is local to a component instance.",
pageId: "hooks/state",
},
],
})
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
const docs = result as Documentation[];
expect(docs.length).toBeGreaterThan(0);
const doc = docs[0];
expect(doc).toHaveProperty("title");
expect(doc).toHaveProperty("content");
expect(doc).toHaveProperty("source");
expect(result).toEqual([
{
title: "State hook",
content: "Store component state.\n\n```tsx\nconst [value] = useState(0);\n```",
source: "hooks/use-state",
},
{
title: "Hooks > State",
content: "State is local to a component instance.",
source: "hooks/state",
},
]);
});
test("should get library context as text with type: txt", async () => {
test("returns text responses unchanged", async () => {
const command = new GetContextCommand("How to use hooks", "/react/react", {
type: "txt",
});
const result = await command.exec(httpClient);
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect((result as string).length).toBeGreaterThan(0);
});
test("should get library context as JSON using client (default)", async () => {
const client = new Context7({
apiKey: process.env.CONTEXT7_API_KEY || process.env.API_KEY!,
});
const result = await client.getContext("How to use hooks", "/react/react");
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const doc = result[0];
expect(doc).toHaveProperty("title");
expect(doc).toHaveProperty("content");
expect(doc).toHaveProperty("source");
});
test("should get library context as text using client with type: txt", async () => {
const client = new Context7({
apiKey: process.env.CONTEXT7_API_KEY || process.env.API_KEY!,
});
const result = await client.getContext("How to use hooks", "/react/react", {
type: "txt",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
await expect(command.exec(requesterWith("documentation text"))).resolves.toBe(
"documentation text"
);
});
});
+11 -20
View File
@@ -2,7 +2,6 @@ import { Command } from "@commands/command";
import type { GetContextOptions, Documentation } from "@commands/types";
import type { ApiContextJsonResponse } from "./types";
import type { Requester } from "@http";
import { Context7Error } from "@error";
import { formatCodeSnippet, formatInfoSnippet } from "@utils/format";
const DEFAULT_TYPE = "json";
@@ -11,30 +10,22 @@ export class GetContextCommand extends Command<Documentation[] | string> {
private readonly responseType: "json" | "txt";
constructor(query: string, libraryId: string, options?: GetContextOptions) {
const queryParams: Record<string, string | number | undefined> = {};
const { type = DEFAULT_TYPE, ...requestOptions } = options ?? {};
queryParams.query = query;
queryParams.libraryId = libraryId;
super(
{
method: "GET",
query: { query, libraryId, type },
...requestOptions,
},
"v2/context"
);
const responseType = options?.type ?? DEFAULT_TYPE;
queryParams.type = responseType;
super({ method: "GET", query: queryParams }, "v2/context");
this.responseType = responseType;
this.responseType = type;
}
public override async exec(client: Requester): Promise<Documentation[] | string> {
const { result } = await client.request<string | ApiContextJsonResponse>({
method: this.request.method || "GET",
path: [this.endpoint],
query: this.request.query,
body: this.request.body,
});
if (result === undefined) {
throw new Context7Error("Request did not return a result");
}
const result = await this.requestResult<string | ApiContextJsonResponse>(client);
if (this.responseType === "txt" && typeof result === "string") {
return result;
@@ -1,45 +1,64 @@
import { describe, test, expect } from "vitest";
import { SearchLibraryCommand } from "./index";
import { newHttpClient } from "../../utils/test-utils";
import { Context7 } from "../../client";
import { Context7Error } from "@error";
import { requesterWith } from "@utils/test-utils";
const httpClient = newHttpClient();
const apiResult = {
results: [
{
id: "/facebook/react",
title: "React",
description: "A UI library",
totalSnippets: 42,
trustScore: 10,
benchmarkScore: 95,
versions: ["v19"],
},
],
};
describe("SearchLibraryCommand", () => {
test("should search for a library", async () => {
test("maps an API response to libraries", async () => {
const command = new SearchLibraryCommand("I need to build a UI", "react");
const result = await command.exec(httpClient);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const library = result[0];
expect(library).toHaveProperty("id");
expect(library).toHaveProperty("name");
expect(library).toHaveProperty("description");
expect(library).toHaveProperty("totalSnippets");
expect(library).toHaveProperty("trustScore");
expect(library).toHaveProperty("benchmarkScore");
await expect(command.exec(requesterWith(apiResult))).resolves.toEqual([
{
id: "/facebook/react",
name: "React",
description: "A UI library",
totalSnippets: 42,
trustScore: 10,
benchmarkScore: 95,
versions: ["v19"],
},
]);
});
test("should search for a library using client", async () => {
const client = new Context7({
apiKey: process.env.CONTEXT7_API_KEY || process.env.API_KEY!,
test("formats a text response locally", async () => {
const command = new SearchLibraryCommand("I need to build a UI", "react", {
type: "txt",
});
const result = await client.searchLibrary("I need to build a UI", "react");
const result = await command.exec(requesterWith(apiResult));
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain("Context7-compatible library ID: /facebook/react");
expect(result).toContain("Trust Score: High");
});
const library = result[0];
expect(library).toHaveProperty("id");
expect(library).toHaveProperty("name");
expect(library).toHaveProperty("description");
expect(library).toHaveProperty("totalSnippets");
expect(library).toHaveProperty("trustScore");
expect(library).toHaveProperty("benchmarkScore");
test("throws a Context7 error when the response has no result", async () => {
const command = new SearchLibraryCommand("I need to build a UI", "react");
const error = await command.exec(requesterWith(undefined)).catch((e) => e);
expect(error).toBeInstanceOf(Context7Error);
expect(error).toMatchObject({
message: "Request did not return a result",
code: "invalid_response",
});
});
test("rejects missing inputs without making a request", () => {
expect(() => new SearchLibraryCommand("", "react")).toThrow(Context7Error);
expect(() => new SearchLibraryCommand("query", "")).toThrow(Context7Error);
});
});
@@ -15,26 +15,22 @@ export class SearchLibraryCommand extends Command<Library[] | string> {
throw new Context7Error("query and libraryName are required");
}
const queryParams: Record<string, string | number | undefined> = {};
const { type = DEFAULT_TYPE, ...requestOptions } = options ?? {};
queryParams.query = query;
queryParams.libraryName = libraryName;
super(
{
method: "GET",
query: { query, libraryName },
...requestOptions,
},
"v2/libs/search"
);
super({ method: "GET", query: queryParams }, "v2/libs/search");
this.responseType = options?.type ?? DEFAULT_TYPE;
this.responseType = type;
}
public override async exec(client: Requester): Promise<Library[] | string> {
const { result } = await client.request<ApiSearchResponse>({
method: this.request.method || "GET",
path: [this.endpoint],
query: this.request.query,
});
if (result === undefined) {
throw new Context7Error("Request did not return a result");
}
const result = await this.requestResult<ApiSearchResponse>(client);
const libraries = result.results.map(formatLibrary);
+31 -2
View File
@@ -1,5 +1,34 @@
import type { CacheSetting, Context7Fetch, Context7ResponseMetadata, RetryConfig } from "@http";
export interface Context7Config {
apiKey?: string;
/** Override the Context7 API URL, for example when using a proxy. */
baseUrl?: string;
/** Retry transient network and HTTP failures. Set to false to disable retries. */
retry?: RetryConfig;
/** Native fetch cache mode. Defaults to "no-store". */
cache?: CacheSetting;
/** Request timeout in milliseconds. Set to false to disable it. @default 30000 */
timeout?: number | false;
/** Abort all requests made by this client when this signal aborts. */
signal?: AbortSignal | (() => AbortSignal);
/** Whether fetch may keep the connection alive. @default true */
keepAlive?: boolean;
/** Custom fetch implementation for non-standard runtimes, testing, or instrumentation. */
fetch?: Context7Fetch;
/** Additional headers sent with every request. Authorization cannot be overridden. */
headers?: Record<string, string>;
/** Observe response status, request IDs, rate limits, and retry attempts. */
onResponse?: (metadata: Context7ResponseMetadata) => void;
}
export interface Context7RequestOptions {
/** Abort this request. */
signal?: AbortSignal;
/** Override the client timeout for this request. Set to false to disable it. */
timeout?: number | false;
/** Override the native fetch cache mode for this request. */
cache?: CacheSetting;
}
/**
@@ -34,7 +63,7 @@ export interface Documentation {
source: string;
}
export interface GetContextOptions {
export interface GetContextOptions extends Context7RequestOptions {
/**
* Response format.
* - "json": Returns Documentation[] array (default)
@@ -44,7 +73,7 @@ export interface GetContextOptions {
type?: "json" | "txt";
}
export interface SearchLibraryOptions {
export interface SearchLibraryOptions extends Context7RequestOptions {
/**
* Response format.
* - "json": Returns Library[] array (default)
+54 -1
View File
@@ -1,6 +1,59 @@
import type { RateLimitMetadata } from "../http/types";
export type Context7ErrorOptions = {
/** Machine-readable error code returned by Context7 or generated by the SDK. */
code?: string;
/** HTTP status code, when the request reached the Context7 API. */
status?: number;
/** Request identifier returned by the Context7 API. */
requestId?: string;
/** Rate-limit state returned with the failed request. */
rateLimit?: RateLimitMetadata;
/** Whether retrying the request may succeed. */
retryable?: boolean;
/** Original error that caused this error. */
cause?: unknown;
};
export class Context7Error extends Error {
constructor(message: string) {
public readonly code?: string;
public readonly status?: number;
public readonly requestId?: string;
public readonly rateLimit?: RateLimitMetadata;
public readonly retryable: boolean;
public override readonly cause?: unknown;
constructor(message: string, options: Context7ErrorOptions = {}) {
super(message);
this.name = "Context7Error";
this.code = options.code;
this.status = options.status;
this.requestId = options.requestId;
this.rateLimit = options.rateLimit;
this.retryable = options.retryable ?? false;
this.cause = options.cause;
}
}
/** Raised when a configured API URL is not a valid HTTP(S) URL. */
export class Context7UrlError extends Context7Error {
constructor(url: string) {
super(
`Context7 client was passed an invalid URL. You should pass a URL starting with http:// or https://. Received: "${url}".`,
{ code: "invalid_url" }
);
this.name = "Context7UrlError";
}
}
/** Raised when a response advertised as JSON cannot be parsed. */
export class Context7JSONParseError extends Context7Error {
constructor(body: string, options: Context7ErrorOptions = {}) {
const truncatedBody = body.length > 200 ? `${body.slice(0, 200)}...` : body;
super(`Unable to parse response body: ${truncatedBody}`, {
...options,
code: options.code ?? "invalid_json_response",
});
this.name = "Context7JSONParseError";
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Context7Error } from "@error";
export type AbortState = {
signal?: AbortSignal;
timedOut: () => boolean;
cleanup: () => void;
};
export function validateTimeout(timeout: number | false): void {
if (timeout !== false && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new TypeError("timeout must be a positive number or false");
}
}
export function resolveSignal(signal?: AbortSignal | (() => AbortSignal)): AbortSignal | undefined {
return typeof signal === "function" ? signal() : signal;
}
export function createAbortState(
signals: Array<AbortSignal | undefined>,
timeout: number | false
): AbortState {
validateTimeout(timeout);
const activeSignals = [
...new Set(signals.filter((signal): signal is AbortSignal => signal !== undefined)),
];
if (timeout === false && activeSignals.length === 0) {
return { timedOut: () => false, cleanup: () => undefined };
}
const controller = new AbortController();
let didTimeOut = false;
const listeners = new Map<AbortSignal, () => void>();
for (const signal of activeSignals) {
const abort = () => controller.abort(signal.reason);
if (signal.aborted) {
abort();
break;
}
signal.addEventListener("abort", abort, { once: true });
listeners.set(signal, abort);
}
const timer =
timeout === false
? undefined
: setTimeout(() => {
didTimeOut = true;
controller.abort(new Error(`Request timed out after ${timeout}ms`));
}, timeout);
return {
signal: controller.signal,
timedOut: () => didTimeOut,
cleanup: () => {
if (timer !== undefined) clearTimeout(timer);
for (const [signal, listener] of listeners) {
signal.removeEventListener("abort", listener);
}
},
};
}
export function abortError(cause: unknown, timedOut: boolean): Context7Error {
return new Context7Error(timedOut ? "Request timed out" : "Request was aborted", {
code: timedOut ? "request_timeout" : "request_aborted",
retryable: timedOut,
cause,
});
}
export function isContext7AbortError(error: unknown): boolean {
return (
error instanceof Context7Error &&
(error.code === "request_aborted" || error.code === "request_timeout")
);
}
export async function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (milliseconds <= 0) return;
await new Promise<void>((resolve, reject) => {
const finish = () => {
signal?.removeEventListener("abort", abort);
resolve();
};
const timer = setTimeout(finish, milliseconds);
const abort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
reject(signal?.reason ?? new Error("Request was aborted"));
};
if (signal?.aborted) {
abort();
return;
}
signal?.addEventListener("abort", abort, { once: true });
});
}
+353 -4
View File
@@ -1,6 +1,6 @@
import { describe, test, expect, vi, afterEach } from "vitest";
import { HttpClient } from "./index";
import { Context7Error } from "@error";
import { Context7Error, Context7JSONParseError, Context7UrlError } from "@error";
function newClient(): HttpClient {
return new HttpClient({
@@ -19,6 +19,216 @@ function mockFetch(response: Response) {
describe("HttpClient error handling", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
test("does not retry network errors when retries are disabled", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("network unavailable"));
vi.stubGlobal("fetch", fetchMock);
const error = await newClient()
.request({ path: ["search"] })
.catch((e) => e);
expect(error).toBeInstanceOf(Context7Error);
expect(error).toMatchObject({
message: "network unavailable",
code: "network_error",
retryable: true,
});
expect(error.cause).toBeInstanceOf(Error);
expect(fetchMock).toHaveBeenCalledOnce();
});
test("retries transient HTTP responses and reports every attempt", async () => {
const onResponse = vi.fn();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json", "x-request-id": "req-success" },
})
);
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 1, backoff: () => 0 },
onResponse,
});
await expect(client.request({ method: "GET", path: ["search"] })).resolves.toEqual({
result: { ok: true },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(onResponse).toHaveBeenNthCalledWith(1, { status: 503, attempt: 0 });
expect(onResponse).toHaveBeenNthCalledWith(2, {
status: 200,
attempt: 1,
requestId: "req-success",
});
});
test("honors Retry-After before retrying a rate-limited request", async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response("rate limited", { status: 429, headers: { "retry-after": "1" } })
)
.mockResolvedValueOnce(new Response("ok"));
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 1, backoff: () => 0 },
timeout: false,
});
const request = client.request({ method: "GET", path: ["search"] });
await vi.advanceTimersByTimeAsync(999);
expect(fetchMock).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
await expect(request).resolves.toEqual({ result: "ok", headers: undefined });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test("uses the last failure when a retry later fails on the network", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockRejectedValueOnce(new Error("connection reset"));
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 1, backoff: () => 0 },
});
const error = await client.request({ method: "GET", path: ["search"] }).catch((e) => e);
expect(error).toMatchObject({ code: "network_error", message: "connection reset" });
});
test("does not retry POST requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("unavailable", { status: 503 }));
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 3, backoff: () => 0 },
});
await expect(client.request({ method: "POST", path: ["refresh"] })).rejects.toMatchObject({
status: 503,
retryable: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
});
test("times out a request without retrying after the abort", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn((_url: string | URL, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
});
});
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
timeout: 25,
});
const request = client.request({ path: ["search"] }).catch((e) => e);
await vi.advanceTimersByTimeAsync(25);
const error = await request;
expect(error).toMatchObject({
code: "request_timeout",
message: "Request timed out",
retryable: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
});
test("reports a timeout when the deadline expires during retry backoff", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockResolvedValue(new Response("unavailable", { status: 503 }));
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 1, backoff: () => 1_000 },
timeout: 25,
});
const request = client.request({ method: "GET", path: ["search"] }).catch((e) => e);
await vi.advanceTimersByTimeAsync(25);
const error = await request;
expect(error).toMatchObject({
code: "request_timeout",
message: "Request timed out",
retryable: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
});
test("honors a per-request abort signal", async () => {
const controller = new AbortController();
const fetchMock = vi.fn((_url: string | URL, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
});
});
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
timeout: false,
});
const request = client.request({ path: ["search"], signal: controller.signal }).catch((e) => e);
controller.abort();
const error = await request;
expect(error).toMatchObject({
code: "request_aborted",
message: "Request was aborted",
retryable: false,
});
expect(fetchMock).toHaveBeenCalledOnce();
});
test("does not call fetch for a signal that is already aborted", async () => {
const controller = new AbortController();
controller.abort();
const fetchMock = vi.fn();
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
timeout: false,
});
const error = await client
.request({ path: ["search"], signal: controller.signal })
.catch((e) => e);
expect(error).toMatchObject({ code: "request_aborted" });
expect(fetchMock).not.toHaveBeenCalled();
});
test("does not treat response observer failures as network failures", async () => {
const observerError = new Error("observer failed");
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
const client = new HttpClient({
baseUrl: "https://example.com/api",
fetch: fetchMock,
retry: { retries: 3, backoff: () => 0 },
onResponse: () => {
throw observerError;
},
});
await expect(client.request({ path: ["search"] })).rejects.toBe(observerError);
expect(fetchMock).toHaveBeenCalledOnce();
});
test("throws Context7Error with message from JSON error body", async () => {
@@ -29,9 +239,17 @@ describe("HttpClient error handling", () => {
})
);
await expect(newClient().request({ path: ["search"] })).rejects.toThrowError(
new Context7Error("rate limit exceeded")
);
const error = await newClient()
.request({ path: ["search"] })
.catch((e) => e);
expect(error).toBeInstanceOf(Context7Error);
expect(error).toMatchObject({
message: "rate limit exceeded",
code: "rate limit exceeded",
status: 429,
retryable: true,
});
});
test("falls back to message field when error field is absent", async () => {
@@ -50,6 +268,39 @@ describe("HttpClient error handling", () => {
expect(error.message).toBe("something went wrong");
});
test("includes status, error code, request ID, and rate limits on API errors", async () => {
mockFetch(
new Response(JSON.stringify({ error: "rate_limit_exceeded", message: "Try again later" }), {
status: 429,
headers: {
"content-type": "application/json",
"x-request-id": "req-123",
"ratelimit-limit": "100",
"ratelimit-remaining": "0",
"ratelimit-reset": "1700000000",
"retry-after": "12",
},
})
);
const error = await newClient()
.request({ path: ["search"] })
.catch((e) => e);
expect(error).toMatchObject({
message: "Try again later",
code: "rate_limit_exceeded",
status: 429,
requestId: "req-123",
rateLimit: {
limit: 100,
remaining: 0,
reset: 1700000000,
retryAfter: 12,
},
});
});
test("throws Context7Error (not SyntaxError) on non-JSON error body", async () => {
mockFetch(
new Response("<html><body>502 Bad Gateway</body></html>", {
@@ -68,6 +319,74 @@ describe("HttpClient error handling", () => {
expect(error.message).toBe("Bad Gateway");
});
test("throws a structured parse error for malformed JSON responses", async () => {
const body = "{" + "x".repeat(250);
mockFetch(
new Response(body, {
status: 200,
headers: { "content-type": "application/json", "x-request-id": "req-json" },
})
);
const error = await newClient()
.request({ path: ["search"] })
.catch((e) => e);
expect(error).toBeInstanceOf(Context7JSONParseError);
expect(error).toMatchObject({
code: "invalid_json_response",
status: 200,
requestId: "req-json",
});
expect(error.message).toHaveLength("Unable to parse response body: ".length + 203);
expect(error.cause).toBeInstanceOf(SyntaxError);
});
test("keeps HTTP metadata when an error response contains malformed JSON", async () => {
mockFetch(
new Response("{invalid", {
status: 502,
headers: { "content-type": "application/json", "x-request-id": "req-bad-json" },
})
);
const error = await newClient()
.request({ path: ["search"] })
.catch((e) => e);
expect(error).toBeInstanceOf(Context7JSONParseError);
expect(error).toMatchObject({
code: "invalid_json_response",
status: 502,
requestId: "req-bad-json",
retryable: true,
});
});
test("rejects invalid base URLs", () => {
expect(() => new HttpClient({ baseUrl: "example.com" })).toThrow(Context7UrlError);
expect(() => new HttpClient({ baseUrl: "ftp://example.com" })).toThrow(Context7UrlError);
expect(() => new HttpClient({ baseUrl: " https://example.com" })).toThrow(Context7UrlError);
expect(() => new HttpClient({ baseUrl: "https://example.com\n" })).toThrow(Context7UrlError);
});
test("allows keepalive to be disabled", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
const client = new HttpClient({
baseUrl: "https://example.com",
fetch: fetchMock,
retry: false,
keepAlive: false,
});
await client.request({ path: ["search"] });
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/search",
expect.objectContaining({ keepalive: false })
);
});
test("falls back to statusText on empty error body", async () => {
mockFetch(new Response("", { status: 503, statusText: "Service Unavailable" }));
@@ -78,4 +397,34 @@ describe("HttpClient error handling", () => {
expect(error).toBeInstanceOf(Context7Error);
expect(error.message).toBe("Service Unavailable");
});
test("falls back to statusText when a JSON error body is null", async () => {
mockFetch(
new Response("null", {
status: 500,
statusText: "Internal Server Error",
headers: { "content-type": "application/json" },
})
);
const error = await newClient()
.request({ method: "GET", path: ["search"] })
.catch((e) => e);
expect(error).toBeInstanceOf(Context7Error);
expect(error).toMatchObject({
message: "Internal Server Error",
code: "http_error",
status: 500,
});
});
test("validates timeout and retry configuration", () => {
expect(() => new HttpClient({ baseUrl: "https://example.com", timeout: 0 })).toThrowError(
"timeout must be a positive number or false"
);
expect(
() => new HttpClient({ baseUrl: "https://example.com", retry: { retries: -1 } })
).toThrowError("retry.retries must be a non-negative integer");
});
});
+150 -178
View File
@@ -1,212 +1,184 @@
import { Context7Error } from "@error";
import { Context7Error, Context7UrlError } from "@error";
import {
abortError,
createAbortState,
isContext7AbortError,
resolveSignal,
validateTimeout,
wait,
type AbortState,
} from "./abort";
import { createRetryPolicy, isTransientStatus, retryDelay } from "./retry";
import { extractResponseMetadata, parseSuccessResponse, throwResponseError } from "./response";
import type {
CacheSetting,
Context7Fetch,
Context7Request,
Context7Response,
Context7ResponseMetadata,
HttpClientConfig,
Requester,
RetryPolicy,
} from "./types";
type CacheSetting =
| "default"
| "force-cache"
| "no-cache"
| "no-store"
| "only-if-cached"
| "reload"
| false;
export * from "./types";
export type Context7Request = {
path?: string[];
/**
* Request body will be serialized to json
*/
body?: unknown;
/**
* HTTP method to use
* @default "POST"
*/
method?: "GET" | "POST";
/**
* Query parameters for GET requests
*/
query?: Record<string, string | number | boolean | undefined>;
const DEFAULT_TIMEOUT = 30_000;
type FetchResult = {
response: Response;
metadata: Context7ResponseMetadata;
};
export type TxtResponseHeaders = {
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
totalTokens: number;
};
export type Context7Response<TResult> = {
result?: TResult;
headers?: TxtResponseHeaders;
};
export type Requester = {
request: <TResult = unknown>(req: Context7Request) => Promise<Context7Response<TResult>>;
};
export type RetryConfig =
| false
| {
/**
* The number of retries to attempt before giving up.
*
* @default 5
*/
retries?: number;
/**
* A backoff function receives the current retry count and returns a number in milliseconds to wait before retrying.
*
* @default
* ```ts
* Math.exp(retryCount) * 50
* ```
*/
backoff?: (retryCount: number) => number;
};
export type RequesterConfig = {
/**
* Configure the retry behaviour in case of network errors
*/
retry?: RetryConfig;
/**
* Configure the cache behaviour
* @default "no-store"
*/
cache?: CacheSetting;
};
export type HttpClientConfig = {
headers?: Record<string, string>;
baseUrl: string;
retry?: RetryConfig;
signal?: () => AbortSignal;
} & RequesterConfig;
export class HttpClient implements Requester {
public baseUrl: string;
public headers: Record<string, string>;
public readonly baseUrl: string;
public readonly headers: Record<string, string>;
public readonly options: {
signal?: HttpClientConfig["signal"];
signal?: AbortSignal | (() => AbortSignal);
cache?: CacheSetting;
timeout: number | false;
keepAlive: boolean;
};
public readonly retry: RetryPolicy;
public readonly retry: {
attempts: number;
backoff: (retryCount: number) => number;
};
private readonly fetch: Context7Fetch;
private readonly onResponse?: (metadata: Context7ResponseMetadata) => void;
public constructor(config: HttpClientConfig) {
this.options = {
cache: config.cache,
signal: config.signal,
timeout: config.timeout ?? DEFAULT_TIMEOUT,
keepAlive: config.keepAlive ?? true,
};
validateTimeout(this.options.timeout);
this.baseUrl = config.baseUrl.replace(/\/$/, "");
if (!isHttpUrl(this.baseUrl)) throw new Context7UrlError(this.baseUrl);
this.headers = {
"Content-Type": "application/json",
...config.headers,
};
this.retry =
typeof config?.retry === "boolean" && config?.retry === false
? {
attempts: 1,
backoff: () => 0,
}
: {
attempts: config?.retry?.retries ?? 5,
backoff: config?.retry?.backoff ?? ((retryCount) => Math.exp(retryCount) * 50),
};
this.headers = { "Content-Type": "application/json", ...config.headers };
if (!config.fetch && !globalThis.fetch) {
throw new TypeError("A fetch implementation is required");
}
this.fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
this.onResponse = config.onResponse;
this.retry = createRetryPolicy(config.retry);
}
public async request<TResult>(req: Context7Request): Promise<Context7Response<TResult>> {
const method = req.method || "POST";
let url = [this.baseUrl, ...(req.path ?? [])].join("/");
if (method === "GET" && req.query) {
const queryParams = new URLSearchParams();
Object.entries(req.query).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, String(value));
}
});
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
}
const requestOptions = {
cache: this.options.cache,
public async request<TResult>(request: Context7Request): Promise<Context7Response<TResult>> {
const method = request.method ?? "POST";
const abortState = createAbortState(
[resolveSignal(this.options.signal), request.signal],
request.timeout ?? this.options.timeout
);
const init: RequestInit = {
cache: normalizeCache(request.cache ?? this.options.cache),
method,
headers: this.headers,
body: req.body ? JSON.stringify(req.body) : undefined,
keepalive: true,
signal: this.options.signal?.(),
body: request.body === undefined ? undefined : JSON.stringify(request.body),
keepalive: this.options.keepAlive,
signal: abortState.signal,
};
let res: Response | null = null;
let error: Error | null = null;
for (let i = 0; i <= this.retry.attempts; i++) {
try {
res = await fetch(url, requestOptions as RequestInit);
break;
} catch (error_) {
if (requestOptions.signal?.aborted) {
throw error_;
}
error = error_ as Error;
if (i < this.retry.attempts) {
await new Promise((r) => setTimeout(r, this.retry.backoff(i)));
}
try {
if (abortState.signal?.aborted) {
throw abortError(abortState.signal.reason, abortState.timedOut());
}
}
if (!res) {
throw error ?? new Error("Exhausted all retries");
}
if (!res.ok) {
const errorBody = (await res.json().catch(() => ({}))) as {
error?: string;
message?: string;
};
throw new Context7Error(errorBody.error || errorBody.message || res.statusText);
}
const contentType = res.headers.get("content-type");
if (contentType?.includes("application/json")) {
const body = await res.json();
return { result: body as TResult };
} else {
const text = await res.text();
const headers = this.extractTxtResponseHeaders(res.headers);
return { result: text as TResult, headers };
const { response, metadata } = await this.fetchWithRetry(
buildUrl(this.baseUrl, method, request),
init,
method,
abortState
);
if (!response.ok) {
await throwResponseError(
response,
metadata,
isTransientStatus(response.status) || this.retry.statuses.has(response.status)
);
}
return await parseSuccessResponse<TResult>(response, metadata);
} catch (error) {
if (abortState.signal?.aborted && !isContext7AbortError(error)) {
throw abortError(error, abortState.timedOut());
}
throw error;
} finally {
abortState.cleanup();
}
}
private extractTxtResponseHeaders(headers: Headers): TxtResponseHeaders | undefined {
const page = headers.get("x-context7-page");
const limit = headers.get("x-context7-limit");
const totalPages = headers.get("x-context7-total-pages");
const hasNext = headers.get("x-context7-has-next");
const hasPrev = headers.get("x-context7-has-prev");
const totalTokens = headers.get("x-context7-total-tokens");
private async fetchWithRetry(
url: string,
init: RequestInit,
method: "GET" | "POST",
abortState: AbortState
): Promise<FetchResult> {
const canRetry = method === "GET";
if (!page || !limit || !totalPages || !hasNext || !hasPrev || !totalTokens) {
return undefined;
for (let attempt = 0; attempt <= this.retry.retries; attempt++) {
let response: Response;
try {
response = await this.fetch(url, init);
} catch (cause) {
if (abortState.signal?.aborted) {
throw abortError(cause, abortState.timedOut());
}
if (canRetry && attempt < this.retry.retries) {
await wait(this.retry.backoff(attempt), abortState.signal);
continue;
}
throw new Context7Error(errorMessage(cause), {
code: "network_error",
retryable: true,
cause,
});
}
const metadata = extractResponseMetadata(response, attempt);
this.onResponse?.(metadata);
const shouldRetry =
canRetry && this.retry.statuses.has(response.status) && attempt < this.retry.retries;
if (!shouldRetry) return { response, metadata };
await response.body?.cancel().catch(() => undefined);
await wait(
retryDelay(this.retry.backoff(attempt), metadata.rateLimit?.retryAfter),
abortState.signal
);
}
return {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
totalPages: parseInt(totalPages, 10),
hasNext: hasNext === "true",
hasPrev: hasPrev === "true",
totalTokens: parseInt(totalTokens, 10),
};
throw new Error("Unreachable retry state");
}
}
function buildUrl(baseUrl: string, method: "GET" | "POST", request: Context7Request): string {
const url = [baseUrl, ...(request.path ?? [])].join("/");
if (method !== "GET" || !request.query) return url;
const query = new URLSearchParams();
for (const [key, value] of Object.entries(request.query)) {
if (value !== undefined) query.append(key, String(value));
}
const queryString = query.toString();
return queryString ? `${url}?${queryString}` : url;
}
function isHttpUrl(url: string): boolean {
if (url !== url.trim() || /[\r\n]/.test(url)) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function normalizeCache(cache?: CacheSetting): Exclude<CacheSetting, false> | undefined {
return cache === false ? undefined : cache;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+146
View File
@@ -0,0 +1,146 @@
import { Context7Error, Context7JSONParseError } from "@error";
import type {
Context7Response,
Context7ResponseMetadata,
RateLimitMetadata,
TxtResponseHeaders,
} from "./types";
export function extractResponseMetadata(
response: Response,
attempt: number
): Context7ResponseMetadata {
const rateLimit: RateLimitMetadata = {
limit: parseOptionalNumber(
response.headers.get("ratelimit-limit") ?? response.headers.get("x-ratelimit-limit")
),
remaining: parseOptionalNumber(
response.headers.get("ratelimit-remaining") ?? response.headers.get("x-ratelimit-remaining")
),
reset: parseOptionalNumber(
response.headers.get("ratelimit-reset") ?? response.headers.get("x-ratelimit-reset")
),
retryAfter: parseRetryAfter(response.headers.get("retry-after")),
};
return {
status: response.status,
requestId:
response.headers.get("x-request-id") ??
response.headers.get("x-context7-request-id") ??
undefined,
rateLimit: Object.values(rateLimit).some((value) => value !== undefined)
? rateLimit
: undefined,
attempt,
};
}
export async function parseSuccessResponse<TResult>(
response: Response,
metadata: Context7ResponseMetadata
): Promise<Context7Response<TResult>> {
if (response.headers.get("content-type")?.includes("application/json")) {
return { result: (await parseJson(response, metadata)) as TResult };
}
return {
result: (await response.text()) as TResult,
headers: extractTxtResponseHeaders(response.headers),
};
}
export async function throwResponseError(
response: Response,
metadata: Context7ResponseMetadata,
retryable: boolean
): Promise<never> {
const rawBody = await response.text();
let errorBody: { error?: string; message?: string } = {};
if (rawBody) {
try {
const parsed: unknown = JSON.parse(rawBody);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const { error, message } = parsed as Record<string, unknown>;
errorBody = {
error: typeof error === "string" ? error : undefined,
message: typeof message === "string" ? message : undefined,
};
}
} catch (cause) {
if (response.headers.get("content-type")?.includes("application/json")) {
throw jsonParseError(rawBody, metadata, cause, retryable);
}
}
}
throw new Context7Error(errorBody.message || errorBody.error || response.statusText, {
code: errorBody.error ?? "http_error",
status: response.status,
requestId: metadata.requestId,
rateLimit: metadata.rateLimit,
retryable,
});
}
async function parseJson(response: Response, metadata: Context7ResponseMetadata): Promise<unknown> {
const rawBody = await response.text();
try {
return JSON.parse(rawBody);
} catch (cause) {
throw jsonParseError(rawBody, metadata, cause, false);
}
}
function jsonParseError(
body: string,
metadata: Context7ResponseMetadata,
cause: unknown,
retryable: boolean
): Context7JSONParseError {
return new Context7JSONParseError(body, {
status: metadata.status,
requestId: metadata.requestId,
rateLimit: metadata.rateLimit,
retryable,
cause,
});
}
function extractTxtResponseHeaders(headers: Headers): TxtResponseHeaders | undefined {
const page = headers.get("x-context7-page");
const limit = headers.get("x-context7-limit");
const totalPages = headers.get("x-context7-total-pages");
const hasNext = headers.get("x-context7-has-next");
const hasPrev = headers.get("x-context7-has-prev");
const totalTokens = headers.get("x-context7-total-tokens");
if (!page || !limit || !totalPages || !hasNext || !hasPrev || !totalTokens) return undefined;
return {
page: Number.parseInt(page, 10),
limit: Number.parseInt(limit, 10),
totalPages: Number.parseInt(totalPages, 10),
hasNext: hasNext === "true",
hasPrev: hasPrev === "true",
totalTokens: Number.parseInt(totalTokens, 10),
};
}
function parseOptionalNumber(value: string | null): number | undefined {
if (value === null) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseRetryAfter(value: string | null): number | undefined {
if (value === null) return undefined;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds);
const date = Date.parse(value);
if (Number.isNaN(date)) return undefined;
return Math.max(0, Math.ceil((date - Date.now()) / 1000));
}
+28
View File
@@ -0,0 +1,28 @@
import type { RetryConfig, RetryPolicy } from "./types";
export const DEFAULT_RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
export function createRetryPolicy(config?: RetryConfig): RetryPolicy {
const retries = config === false ? 0 : (config?.retries ?? 5);
if (!Number.isInteger(retries) || retries < 0) {
throw new TypeError("retry.retries must be a non-negative integer");
}
return {
retries,
backoff: config === false ? () => 0 : (config?.backoff ?? defaultBackoff),
statuses: new Set(config === false ? [] : (config?.statuses ?? DEFAULT_RETRY_STATUSES)),
};
}
export function isTransientStatus(status: number): boolean {
return DEFAULT_RETRY_STATUSES.has(status);
}
export function retryDelay(backoff: number, retryAfter?: number): number {
return Math.max(backoff, retryAfter === undefined ? 0 : retryAfter * 1000);
}
function defaultBackoff(retryCount: number): number {
return Math.exp(retryCount) * 50;
}
+101
View File
@@ -0,0 +1,101 @@
export type CacheSetting =
| "default"
| "force-cache"
| "no-cache"
| "no-store"
| "only-if-cached"
| "reload"
| false;
export type Context7Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
export type RateLimitMetadata = {
/** Maximum requests allowed in the current rate-limit window. */
limit?: number;
/** Requests remaining in the current rate-limit window. */
remaining?: number;
/** Unix timestamp, in seconds, when the current rate-limit window resets. */
reset?: number;
/** Server-requested delay, in seconds, before the next request. */
retryAfter?: number;
};
export type Context7ResponseMetadata = {
status: number;
requestId?: string;
rateLimit?: RateLimitMetadata;
/** Zero-based retry attempt. The first request is attempt 0. */
attempt: number;
};
export type Context7Request = {
path?: string[];
/** Request body will be serialized to JSON. */
body?: unknown;
/** @default "POST" */
method?: "GET" | "POST";
/** Query parameters for GET requests. */
query?: Record<string, string | number | boolean | undefined>;
/** Abort this request. */
signal?: AbortSignal;
/** Override the client timeout for this request. Set to false to disable it. */
timeout?: number | false;
/** Override the native fetch cache mode for this request. */
cache?: CacheSetting;
};
export type TxtResponseHeaders = {
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
totalTokens: number;
};
export type Context7Response<TResult> = {
result?: TResult;
headers?: TxtResponseHeaders;
};
export type Requester = {
request: <TResult = unknown>(req: Context7Request) => Promise<Context7Response<TResult>>;
};
export type RetryConfig =
| false
| {
/** @default 5 */
retries?: number;
/**
* Receives the zero-based retry count and returns milliseconds to wait.
* @default Math.exp(retryCount) * 50
*/
backoff?: (retryCount: number) => number;
/** HTTP statuses that may be retried. */
statuses?: readonly number[];
};
export type RequesterConfig = {
/** Configure retries for network errors and transient HTTP responses. */
retry?: RetryConfig;
/** @default "no-store" */
cache?: CacheSetting;
};
export type HttpClientConfig = {
headers?: Record<string, string>;
baseUrl: string;
signal?: AbortSignal | (() => AbortSignal);
timeout?: number | false;
/** @default true */
keepAlive?: boolean;
fetch?: Context7Fetch;
onResponse?: (metadata: Context7ResponseMetadata) => void;
} & RequesterConfig;
export type RetryPolicy = {
retries: number;
backoff: (retryCount: number) => number;
statuses: ReadonlySet<number>;
};
+5 -19
View File
@@ -1,21 +1,7 @@
import { HttpClient } from "@http";
import type { Requester } from "@http";
export function newHttpClient(): HttpClient {
const apiKey = process.env.CONTEXT7_API_KEY || process.env.API_KEY;
if (!apiKey) {
throw new Error("CONTEXT7_API_KEY or API_KEY environment variable is required for tests");
}
return new HttpClient({
baseUrl: process.env.CONTEXT7_BASE_URL || "https://context7.com/api",
headers: {
Authorization: `Bearer ${apiKey}`,
},
retry: {
retries: 3,
backoff: (retryCount) => Math.exp(retryCount) * 50,
},
cache: "no-store",
});
export function requesterWith(result: unknown): Requester {
return {
request: async <TResult>() => ({ result: result as TResult }),
};
}
+7 -1
View File
@@ -19,6 +19,12 @@
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src/**/*", "tsup.config.ts", "vitest.config.ts", "eslint.config.js"],
"include": [
"src/**/*",
"tsup.config.ts",
"vitest.config.ts",
"vitest.integration.config.ts",
"eslint.config.js"
],
"exclude": ["node_modules", "dist"]
}
+10 -8
View File
@@ -1,17 +1,10 @@
import { defineConfig } from "vitest/config";
import path from "path";
import dotenv from "dotenv";
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
export default defineConfig({
export const sharedConfig = {
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
env: process.env,
// These tests call the live API, so the 5s default fails on latency alone.
testTimeout: 30_000,
},
resolve: {
alias: {
@@ -21,4 +14,13 @@ export default defineConfig({
"@utils": path.resolve(__dirname, "./src/utils"),
},
},
};
export default defineConfig({
...sharedConfig,
test: {
...sharedConfig.test,
include: ["src/**/*.test.ts"],
exclude: ["src/**/*.integration.test.ts"],
},
});
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import path from "path";
import dotenv from "dotenv";
import { sharedConfig } from "./vitest.config";
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
export default defineConfig({
...sharedConfig,
test: {
...sharedConfig.test,
include: ["src/**/*.integration.test.ts"],
testTimeout: 30_000,
},
});