Skip to content

Commit 4eff2b9

Browse files
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
1 parent a37d30c commit 4eff2b9

28 files changed

Lines changed: 1573 additions & 530 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@upstash/context7-sdk": minor
3+
---
4+
5+
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.
6+
7+
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.
8+
9+
Requests now time out after 30 seconds by default. Set `timeout: false` on the client or an individual request to disable the timeout.

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,9 @@ jobs:
8282
run: pnpm test
8383
env:
8484
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}
85+
86+
- name: SDK Integration Test
87+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
88+
run: pnpm --filter @upstash/context7-sdk test:integration
89+
env:
90+
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}

docs/sdks/ts/commands/get-context.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ Retrieve documentation context for a specific library. Returns documentation as
2626

2727
Default: `"json"`
2828
</ParamField>
29+
<ParamField path="signal" type="AbortSignal">
30+
Abort signal for cancelling this request.
31+
</ParamField>
32+
<ParamField path="timeout" type="number | false">
33+
Per-request timeout in milliseconds. Use `false` to disable the client timeout.
34+
</ParamField>
35+
<ParamField path="cache" type="CacheSetting">
36+
Native fetch cache mode. Use `false` to omit the cache option.
37+
</ParamField>
2938
</Expandable>
3039
</ParamField>
3140

docs/sdks/ts/commands/search-library.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ Search across available libraries. Returns an array of matching libraries with m
1717
The library name to search for
1818
</ParamField>
1919

20+
<ParamField path="options" type="SearchLibraryOptions">
21+
<Expandable title="properties">
22+
<ParamField path="type" type="'json' | 'txt'">
23+
Format of the response. Defaults to `json`.
24+
</ParamField>
25+
<ParamField path="signal" type="AbortSignal">
26+
Abort signal for cancelling this request.
27+
</ParamField>
28+
<ParamField path="timeout" type="number | false">
29+
Per-request timeout in milliseconds. Use `false` to disable the client timeout.
30+
</ParamField>
31+
<ParamField path="cache" type="CacheSetting">
32+
Native fetch cache mode. Use `false` to omit the cache option.
33+
</ParamField>
34+
</Expandable>
35+
</ParamField>
36+
2037
## Response
2138

2239
Returns `Library[]` - an array of library objects.

docs/sdks/ts/getting-started.mdx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,42 @@ const client = new Context7({
7979
`process.env.CONTEXT7_API_KEY`
8080
</Note>
8181

82+
#### Production HTTP configuration
83+
84+
The SDK applies a 30-second request timeout and retries transient network failures, `408`, `425`,
85+
`429`, and `5xx` responses. Only `GET` requests are retried; mutating requests remain
86+
single-attempt.
87+
88+
```typescript
89+
const client = new Context7({
90+
apiKey: "YOUR_API_KEY",
91+
timeout: 10_000,
92+
retry: {
93+
retries: 3,
94+
backoff: (attempt) => 100 * 2 ** attempt,
95+
},
96+
onResponse: ({ status, requestId, rateLimit, attempt }) => {
97+
console.log({ status, requestId, rateLimit, attempt });
98+
},
99+
});
100+
```
101+
102+
You can also configure `baseUrl`, additional `headers`, `keepAlive`, the native fetch `cache` mode,
103+
a client-wide abort `signal`, or a custom `fetch` implementation. The SDK always sets
104+
`Authorization` from the configured API key; additional headers cannot override it.
105+
106+
Following the same convention as `@upstash/redis`, a signal factory can provide a fresh timeout
107+
signal for each request:
108+
109+
```typescript
110+
const client = new Context7({
111+
apiKey: "YOUR_API_KEY",
112+
signal: () => AbortSignal.timeout(10_000),
113+
});
114+
```
115+
116+
Set `retry: false` to make exactly one request or `timeout: false` to disable the default timeout.
117+
82118
## Quick Start Example
83119

84120
```typescript
@@ -104,6 +140,7 @@ console.log(docs[0].title, docs[0].content);
104140
// Get documentation context as plain text
105141
const context = await client.getContext("How do I use hooks?", "/facebook/react", {
106142
type: "txt",
143+
timeout: 5_000,
107144
});
108145
console.log(context);
109146
```
@@ -121,7 +158,14 @@ try {
121158
const context = await client.getContext("query", "/invalid/library");
122159
} catch (error) {
123160
if (error instanceof Context7Error) {
124-
console.error("Context7 API Error:", error.message);
161+
console.error("Context7 API Error:", {
162+
message: error.message,
163+
code: error.code,
164+
status: error.status,
165+
requestId: error.requestId,
166+
rateLimit: error.rateLimit,
167+
retryable: error.retryable,
168+
});
125169
} else {
126170
console.error("Unexpected error:", error);
127171
}

packages/sdk/README.md

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,15 @@ const client = new Context7({
4040
});
4141

4242
// Search for libraries
43-
const libraries = await client.searchLibrary(
44-
"I need to build a UI with components",
45-
"react"
46-
);
43+
const libraries = await client.searchLibrary("I need to build a UI with components", "react");
4744
console.log(libraries[0].id); // "/facebook/react"
4845

4946
// Get documentation as JSON array (default)
5047
const docs = await client.getContext("How do I use hooks?", "/facebook/react");
5148
console.log(docs[0].title, docs[0].content);
5249

5350
// Get documentation context as plain text
54-
const context = await client.getContext(
55-
"How do I use hooks?",
56-
"/facebook/react",
57-
{ type: "txt" }
58-
);
51+
const context = await client.getContext("How do I use hooks?", "/facebook/react", { type: "txt" });
5952
console.log(context);
6053
```
6154

@@ -75,6 +68,61 @@ Then initialize without options:
7568
const client = new Context7();
7669
```
7770

71+
### Production HTTP options
72+
73+
Requests time out after 30 seconds and retry transient network errors, `408`, `425`, `429`,
74+
and `5xx` responses by default. You can configure those defaults for the client and override
75+
timeout, cancellation, and native fetch caching per request:
76+
77+
```ts
78+
import { Context7, Context7Error } from "@upstash/context7-sdk";
79+
80+
const client = new Context7({
81+
apiKey: process.env.CONTEXT7_API_KEY,
82+
timeout: 10_000,
83+
retry: {
84+
retries: 3,
85+
backoff: (attempt) => 100 * 2 ** attempt,
86+
},
87+
onResponse: ({ status, requestId, rateLimit, attempt }) => {
88+
console.log({ status, requestId, rateLimit, attempt });
89+
},
90+
});
91+
92+
const controller = new AbortController();
93+
94+
try {
95+
const docs = await client.getContext("How do I use hooks?", "/facebook/react", {
96+
signal: controller.signal,
97+
timeout: 5_000,
98+
cache: "no-store",
99+
});
100+
console.log(docs);
101+
} catch (error) {
102+
if (error instanceof Context7Error) {
103+
console.error(error.code, error.status, error.requestId, error.rateLimit);
104+
}
105+
}
106+
```
107+
108+
The client also accepts `baseUrl`, `headers`, `keepAlive`, and a custom `fetch` implementation for
109+
proxies, instrumentation, tests, and runtimes that do not expose a global `fetch`. The configured
110+
API key always controls the `Authorization` header.
111+
112+
As in `@upstash/redis`, you can express a timeout with a fresh signal for every request:
113+
114+
```ts
115+
const client = new Context7({
116+
apiKey: process.env.CONTEXT7_API_KEY,
117+
signal: () => AbortSignal.timeout(10_000),
118+
});
119+
```
120+
121+
Set `retry: false` to make exactly one request, `timeout: false` to disable the request timeout,
122+
or `cache: false` to omit the native fetch cache option.
123+
124+
Only `GET` requests are retried. Mutating requests remain single-attempt.
125+
78126
## Docs
79127

80128
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
87135
pnpm test
88136
```
89137

138+
Run the live API integration tests separately with a configured API key:
139+
140+
```sh
141+
pnpm test:integration
142+
```
143+
90144
### Building
91145

92146
```sh

packages/sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"build": "tsup",
77
"test": "vitest run",
8+
"test:integration": "vitest run --config vitest.integration.config.ts",
89
"test:watch": "vitest",
910
"typecheck": "tsc --noEmit",
1011
"dev": "tsup --watch",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, test, expect } from "vitest";
2+
import { Context7 } from "./client";
3+
4+
describe("Context7 Client integration", () => {
5+
const apiKey = process.env.CONTEXT7_API_KEY!;
6+
7+
describe("searchLibrary", () => {
8+
const client = new Context7({ apiKey });
9+
10+
test("should search for libraries and return array directly", async () => {
11+
const result = await client.searchLibrary("I need to build a UI", "react");
12+
13+
expect(result).toBeDefined();
14+
expect(Array.isArray(result)).toBe(true);
15+
expect(result.length).toBeGreaterThan(0);
16+
});
17+
18+
test("should return Library objects with all fields", async () => {
19+
const result = await client.searchLibrary("I want to use TypeScript", "typescript");
20+
21+
expect(result.length).toBeGreaterThan(0);
22+
const library = result[0];
23+
24+
expect(library).toHaveProperty("id");
25+
expect(library).toHaveProperty("name");
26+
expect(library).toHaveProperty("description");
27+
expect(library).toHaveProperty("totalSnippets");
28+
expect(library).toHaveProperty("trustScore");
29+
expect(library).toHaveProperty("benchmarkScore");
30+
});
31+
32+
test("should search with different queries", async () => {
33+
const queries = ["vue", "express", "next"];
34+
35+
for (const query of queries) {
36+
const result = await client.searchLibrary(`I want to use ${query}`, query);
37+
expect(result.length).toBeGreaterThan(0);
38+
}
39+
}, 15000);
40+
});
41+
42+
describe("getContext - JSON format (default)", () => {
43+
const client = new Context7({ apiKey });
44+
45+
test("should get context as Documentation array (default)", async () => {
46+
const result = await client.getContext("How to use hooks", "/react/react");
47+
48+
expect(result).toBeDefined();
49+
expect(Array.isArray(result)).toBe(true);
50+
expect(result.length).toBeGreaterThan(0);
51+
});
52+
53+
test("should get context with explicit json type", async () => {
54+
const result = await client.getContext("How to use hooks", "/react/react", {
55+
type: "json",
56+
});
57+
58+
expect(result).toBeDefined();
59+
expect(Array.isArray(result)).toBe(true);
60+
expect(result.length).toBeGreaterThan(0);
61+
});
62+
63+
test("should have correct Documentation structure", async () => {
64+
const result = await client.getContext("How to use hooks", "/react/react", {
65+
type: "json",
66+
});
67+
68+
expect(result.length).toBeGreaterThan(0);
69+
const doc = result[0];
70+
expect(doc).toHaveProperty("title");
71+
expect(doc).toHaveProperty("content");
72+
expect(doc).toHaveProperty("source");
73+
expect(typeof doc.title).toBe("string");
74+
expect(typeof doc.content).toBe("string");
75+
expect(typeof doc.source).toBe("string");
76+
});
77+
});
78+
79+
describe("getContext - text format", () => {
80+
const client = new Context7({ apiKey });
81+
82+
test("should get context as text string with type: txt", async () => {
83+
const result = await client.getContext("How to use hooks", "/react/react", {
84+
type: "txt",
85+
});
86+
87+
expect(result).toBeDefined();
88+
expect(typeof result).toBe("string");
89+
expect(result.length).toBeGreaterThan(0);
90+
});
91+
});
92+
93+
describe("getContext - different libraries", () => {
94+
const client = new Context7({ apiKey });
95+
96+
test("should get context for Vue", async () => {
97+
const result = await client.getContext("How to create components", "/vuejs/core");
98+
99+
expect(result).toBeDefined();
100+
expect(Array.isArray(result)).toBe(true);
101+
expect(result.length).toBeGreaterThan(0);
102+
});
103+
104+
test("should get context for Express", async () => {
105+
const result = await client.getContext("How to create routes", "/expressjs/express");
106+
107+
expect(result).toBeDefined();
108+
expect(Array.isArray(result)).toBe(true);
109+
expect(result.length).toBeGreaterThan(0);
110+
});
111+
});
112+
113+
describe("live error handling", () => {
114+
const client = new Context7({ apiKey });
115+
116+
test("should handle invalid library ID gracefully", async () => {
117+
await expect(client.getContext("test query", "/nonexistent/library")).rejects.toThrow();
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)