@rustrak/client 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,17 +58,24 @@ const client = new RustrakClient({
58
58
  token: process.env.RUSTRAK_API_TOKEN!,
59
59
  });
60
60
 
61
- // List all projects
61
+ // Every method returns a Result. Nothing throws for an expected failure.
62
62
  const projects = await client.projects.list();
63
+ if (!projects.success) {
64
+ console.error(projects.error.kind, projects.error.message);
65
+ return;
66
+ }
67
+ console.log(projects.data.items);
63
68
 
64
69
  // Paginate through open issues
65
- const { items, next_cursor, has_more } = await client.issues.list(1, {
66
- sort: 'last_seen',
67
- order: 'desc',
68
- });
70
+ const issues = await client.issues.list(1, { sort: 'last_seen', order: 'desc' });
71
+ if (issues.success) {
72
+ const { items, next_cursor, has_more } = issues.data;
73
+ }
69
74
 
70
75
  // Resolve an issue
71
- await client.issues.updateState(1, 'issue-id', { is_resolved: true });
76
+ const resolved = await client.issues.updateState(1, 'issue-id', {
77
+ is_resolved: true,
78
+ });
72
79
  ```
73
80
 
74
81
  **[Full documentation →](https://rustrak.github.io/rustrak/sdks/client)**
@@ -78,9 +85,10 @@ await client.issues.updateState(1, 'issue-id', { is_resolved: true });
78
85
  - **Type-safe** — All API responses validated at runtime with Zod; types are inferred from schemas
79
86
  - **Lightweight** — ~28 KB total (ky 3 KB + zod 10 KB + client 15 KB)
80
87
  - **Automatic retry** — Exponential backoff on transient failures (408, 429, 5xx)
81
- - **Structured errors** — Typed error classes for every HTTP status and failure mode
88
+ - **No exceptions**: every method returns `Result<T, RustrakError>`, so failure is in the type, and the value survives the React server/client boundary
82
89
  - **Cursor pagination** — First-class support for paginated responses across all list endpoints
83
- - **97% test coverage** — 133 tests (unit + integration with MSW)
90
+ - **Redacted 5xx**: a server error never carries a server-supplied message, so internal detail cannot reach a UI
91
+ - **High test coverage**: 430+ tests (unit + integration with MSW)
84
92
 
85
93
  ## API Reference
86
94
 
@@ -135,44 +143,108 @@ console.log(event.data); // Full Sentry event payload
135
143
  ```typescript
136
144
  const tokens = await client.tokens.list();
137
145
  const created = await client.tokens.create({ description: 'CI token' });
138
- console.log(created.token); // Save this — shown only once
146
+ if (created.success) {
147
+ console.log(created.data.token); // Save this, shown only once
148
+ }
139
149
  await client.tokens.delete(1);
140
150
  ```
141
151
 
142
152
  ## Error Handling
143
153
 
154
+ Resource methods do not throw. Each returns a `Result`, a plain discriminated
155
+ union mirroring Zod's `safeParse`:
156
+
144
157
  ```typescript
145
- import {
146
- AuthenticationError,
147
- AuthorizationError,
148
- NotFoundError,
149
- RateLimitError,
150
- NetworkError,
151
- ServerError,
152
- ValidationError,
153
- } from '@rustrak/client';
158
+ type Result<T, E> =
159
+ | { success: true; data: T }
160
+ | { success: false; error: E };
161
+ ```
154
162
 
155
- try {
156
- await client.projects.list();
157
- } catch (error) {
158
- if (error instanceof RateLimitError) console.log(`Retry after ${error.retryAfter}s`);
159
- else if (error instanceof AuthenticationError) redirect('/login');
160
- else if (error instanceof NotFoundError) console.log('Not found');
161
- else if (error instanceof NetworkError) console.log('Will retry automatically');
162
- else if (error instanceof ValidationError) console.log(error.getValidationDetails());
163
+ Failures are one closed union keyed on `kind`, so the compiler forces you to
164
+ handle the case you care about and lets you ignore the rest deliberately:
165
+
166
+ ```typescript
167
+ import { isRetryable, type RustrakError } from '@rustrak/client';
168
+
169
+ const result = await client.projects.list();
170
+
171
+ if (!result.success) {
172
+ switch (result.error.kind) {
173
+ case 'unauthenticated':
174
+ redirect('/login');
175
+ break;
176
+ case 'rate_limited':
177
+ console.log(`Retry after ${result.error.retryAfter ?? '?'}s`);
178
+ break;
179
+ case 'not_found':
180
+ console.log('Gone');
181
+ break;
182
+ default:
183
+ if (isRetryable(result.error)) scheduleRetry();
184
+ else console.error(result.error.message);
185
+ }
186
+ return;
163
187
  }
188
+
189
+ console.log(result.data.items);
164
190
  ```
165
191
 
166
- | Error Class | HTTP | Retryable |
167
- |---|---|---|
168
- | `NetworkError` | — | ✅ |
169
- | `AuthenticationError` | 401 | ❌ |
170
- | `AuthorizationError` | 403 | ❌ |
171
- | `NotFoundError` | 404 | ❌ |
172
- | `BadRequestError` | 400 | ❌ |
173
- | `RateLimitError` | 429 | ✅ |
174
- | `ServerError` | 500+ | ✅ |
175
- | `ValidationError` | — | ❌ |
192
+ ### The error union
193
+
194
+ | `kind` | HTTP | `status` | Retryable | Notes |
195
+ |---|---|---|---|---|
196
+ | `validation` | 400 | ✅ | ❌ | The server rejected the request |
197
+ | `unauthenticated` | 401 | ✅ | ❌ | No session, or it expired |
198
+ | `forbidden` | 403 | ✅ | ❌ | Authenticated but not allowed |
199
+ | `not_found` | 404 | ✅ | ❌ | |
200
+ | `conflict` | 409 | ✅ | ❌ | |
201
+ | `gone` | 410 | ✅ | ❌ | |
202
+ | `payload_too_large` | 413 | ✅ | ❌ | Envelope ingestion only |
203
+ | `rate_limited` | 429 | ✅ | ✅ | Optional `retryAfter` in seconds |
204
+ | `client_error` | other 4xx | ✅ | ❌ | Catch-all, so no status is unrepresentable |
205
+ | `server_error` | 5xx | ✅ | ✅ | **Message is always a fixed generic string** |
206
+ | `network` | n/a | no | ✅ | DNS, refused connection, TLS, timeout |
207
+ | `invalid_response` | n/a | no | ❌ | 2xx whose body failed its schema |
208
+ | `invalid_request` | n/a | no | ❌ | Your input failed a pre-flight check; nothing was sent |
209
+
210
+ `status` is `number`, never a literal, so a proxy-generated 502 or a future
211
+ status is a value you can log rather than a type error.
212
+
213
+ Three things the union deliberately does **not** carry, because each embeds
214
+ information that must not cross a serialization boundary:
215
+
216
+ - `server_error` never carries the server's message. `AppError::Internal` and
217
+ `AppError::Database` interpolate arbitrary internal text (a pool error, an OS
218
+ errno, a filesystem path); the client discards it at construction and
219
+ substitutes `SERVER_ERROR_MESSAGE`.
220
+ - `network` carries no `cause`. The underlying error embeds the resolved host
221
+ and port.
222
+ - `invalid_response` carries no Zod issues. They embed the offending response
223
+ data.
224
+
225
+ ### Result helpers
226
+
227
+ ```typescript
228
+ import { Ok, Err, unwrap, unwrapOr, mapResult } from '@rustrak/client';
229
+
230
+ mapResult(await client.projects.get(1), (project) => project.name);
231
+ unwrap(await client.projects.get(1)); // throws on failure: opting back in
232
+ unwrapOr(await client.stats.summary(1, "24h"), null); // see the warning below
233
+ ```
234
+
235
+ > **`unwrapOr` is not for rendering.** `unwrapOr(await client.projects.list(),
236
+ > [])` shows the same empty state for "this account has no projects" and "the
237
+ > server is unreachable": the exact confusion the `Result` API exists to
238
+ > prevent, one line after the failure was produced. Use it only where the
239
+ > fallback is correct regardless of *why* the call failed (a cached-count
240
+ > optimisation, a best-effort telemetry read, a script that already logged the
241
+ > error). Anywhere a human sees the outcome, branch on `result.success` and read
242
+ > `error.kind`.
243
+
244
+ Every value in a `Result` is a plain object with `Object.prototype`. That is
245
+ what lets a failure be returned from a Server Component or a Server Action and
246
+ read in a Client Component: React's Flight serializer rejects class instances,
247
+ which is why there are no error classes here.
176
248
 
177
249
  ## Next.js Integration
178
250
 
@@ -187,7 +259,16 @@ export default async function ProjectsPage() {
187
259
  token: process.env.RUSTRAK_API_TOKEN!,
188
260
  });
189
261
  const projects = await client.projects.list();
190
- return <ProjectsList projects={projects} />;
262
+
263
+ if (!projects.success) {
264
+ // `kind` is what decides: only 'unauthenticated' means "send to login".
265
+ // Redirecting on 'network' or 'server_error' turns a flaky connection
266
+ // into a login loop.
267
+ if (projects.error.kind === 'unauthenticated') redirect('/auth/login');
268
+ return <LoadFailed error={projects.error} />;
269
+ }
270
+
271
+ return <ProjectsList projects={projects.data.items} />;
191
272
  }
192
273
  ```
193
274
 
@@ -202,6 +283,8 @@ export async function resolveIssue(projectId: number, issueId: string) {
202
283
  baseUrl: process.env.RUSTRAK_API_URL!,
203
284
  token: process.env.RUSTRAK_API_TOKEN!,
204
285
  });
286
+ // The Result is returned as-is: it is serializable, so the client component
287
+ // receives the failure instead of an opaque "An error occurred" digest.
205
288
  return client.issues.updateState(projectId, issueId, { is_resolved: true });
206
289
  }
207
290
  ```
@@ -220,6 +303,9 @@ import type {
220
303
  PaginatedResponse,
221
304
  CreateProject,
222
305
  UpdateIssueState,
306
+ Result,
307
+ RustrakError,
308
+ RustrakErrorKind,
223
309
  } from '@rustrak/client';
224
310
  ```
225
311