@debros/network-ts-sdk 0.2.5 → 0.3.2

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/src/core/http.ts CHANGED
@@ -28,14 +28,6 @@ export class HttpClient {
28
28
  setApiKey(apiKey?: string) {
29
29
  this.apiKey = apiKey;
30
30
  // Don't clear JWT - allow both to coexist
31
- if (typeof console !== "undefined") {
32
- console.log(
33
- "[HttpClient] API key set:",
34
- !!apiKey,
35
- "JWT still present:",
36
- !!this.jwt
37
- );
38
- }
39
31
  }
40
32
 
41
33
  setJwt(jwt?: string) {
@@ -54,22 +46,39 @@ export class HttpClient {
54
46
  private getAuthHeaders(path: string): Record<string, string> {
55
47
  const headers: Record<string, string> = {};
56
48
 
57
- // For database, pubsub, and proxy operations, ONLY use API key to avoid JWT user context
49
+ // For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
58
50
  // interfering with namespace-level authorization
59
51
  const isDbOperation = path.includes("/v1/rqlite/");
60
52
  const isPubSubOperation = path.includes("/v1/pubsub/");
61
53
  const isProxyOperation = path.includes("/v1/proxy/");
54
+ const isCacheOperation = path.includes("/v1/cache/");
55
+
56
+ // For auth operations, prefer API key over JWT to ensure proper authentication
57
+ const isAuthOperation = path.includes("/v1/auth/");
62
58
 
63
- if (isDbOperation || isPubSubOperation || isProxyOperation) {
64
- // For database/pubsub/proxy operations: use only API key (preferred for namespace operations)
59
+ if (
60
+ isDbOperation ||
61
+ isPubSubOperation ||
62
+ isProxyOperation ||
63
+ isCacheOperation
64
+ ) {
65
+ // For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations)
65
66
  if (this.apiKey) {
66
67
  headers["X-API-Key"] = this.apiKey;
67
68
  } else if (this.jwt) {
68
69
  // Fallback to JWT if no API key
69
70
  headers["Authorization"] = `Bearer ${this.jwt}`;
70
71
  }
72
+ } else if (isAuthOperation) {
73
+ // For auth operations: prefer API key over JWT (auth endpoints should use explicit API key)
74
+ if (this.apiKey) {
75
+ headers["X-API-Key"] = this.apiKey;
76
+ }
77
+ if (this.jwt) {
78
+ headers["Authorization"] = `Bearer ${this.jwt}`;
79
+ }
71
80
  } else {
72
- // For auth/other operations: send both JWT and API key
81
+ // For other operations: send both JWT and API key
73
82
  if (this.jwt) {
74
83
  headers["Authorization"] = `Bearer ${this.jwt}`;
75
84
  }
@@ -98,6 +107,7 @@ export class HttpClient {
98
107
  timeout?: number; // Per-request timeout override
99
108
  } = {}
100
109
  ): Promise<T> {
110
+ const startTime = performance.now(); // Track request start time
101
111
  const url = new URL(this.baseURL + path);
102
112
  if (options.query) {
103
113
  Object.entries(options.query).forEach(([key, value]) => {
@@ -111,27 +121,6 @@ export class HttpClient {
111
121
  ...options.headers,
112
122
  };
113
123
 
114
- // Debug: Log headers being sent
115
- if (
116
- typeof console !== "undefined" &&
117
- (path.includes("/db/") ||
118
- path.includes("/query") ||
119
- path.includes("/auth/") ||
120
- path.includes("/pubsub/") ||
121
- path.includes("/proxy/"))
122
- ) {
123
- console.log("[HttpClient] Request headers for", path, {
124
- hasAuth: !!headers["Authorization"],
125
- hasApiKey: !!headers["X-API-Key"],
126
- authPrefix: headers["Authorization"]
127
- ? headers["Authorization"].substring(0, 20)
128
- : "none",
129
- apiKeyPrefix: headers["X-API-Key"]
130
- ? headers["X-API-Key"].substring(0, 20)
131
- : "none",
132
- });
133
- }
134
-
135
124
  const controller = new AbortController();
136
125
  const requestTimeout = options.timeout ?? this.timeout; // Use override or default
137
126
  const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
@@ -146,8 +135,99 @@ export class HttpClient {
146
135
  fetchOptions.body = JSON.stringify(options.body);
147
136
  }
148
137
 
138
+ // Extract and log SQL query details for rqlite operations
139
+ const isRqliteOperation = path.includes("/v1/rqlite/");
140
+ let queryDetails: string | null = null;
141
+ if (isRqliteOperation && options.body) {
142
+ try {
143
+ const body =
144
+ typeof options.body === "string"
145
+ ? JSON.parse(options.body)
146
+ : options.body;
147
+
148
+ if (body.sql) {
149
+ // Direct SQL query (query/exec endpoints)
150
+ queryDetails = `SQL: ${body.sql}`;
151
+ if (body.args && body.args.length > 0) {
152
+ queryDetails += ` | Args: [${body.args
153
+ .map((a: any) => (typeof a === "string" ? `"${a}"` : a))
154
+ .join(", ")}]`;
155
+ }
156
+ } else if (body.table) {
157
+ // Table-based query (find/find-one/select endpoints)
158
+ queryDetails = `Table: ${body.table}`;
159
+ if (body.criteria && Object.keys(body.criteria).length > 0) {
160
+ queryDetails += ` | Criteria: ${JSON.stringify(body.criteria)}`;
161
+ }
162
+ if (body.options) {
163
+ queryDetails += ` | Options: ${JSON.stringify(body.options)}`;
164
+ }
165
+ if (body.select) {
166
+ queryDetails += ` | Select: ${JSON.stringify(body.select)}`;
167
+ }
168
+ if (body.where) {
169
+ queryDetails += ` | Where: ${JSON.stringify(body.where)}`;
170
+ }
171
+ if (body.limit) {
172
+ queryDetails += ` | Limit: ${body.limit}`;
173
+ }
174
+ if (body.offset) {
175
+ queryDetails += ` | Offset: ${body.offset}`;
176
+ }
177
+ }
178
+ } catch (e) {
179
+ // Failed to parse body, ignore
180
+ }
181
+ }
182
+
149
183
  try {
150
- return await this.requestWithRetry(url.toString(), fetchOptions);
184
+ const result = await this.requestWithRetry(
185
+ url.toString(),
186
+ fetchOptions,
187
+ 0,
188
+ startTime
189
+ );
190
+ const duration = performance.now() - startTime;
191
+ if (typeof console !== "undefined") {
192
+ const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
193
+ 2
194
+ )}ms`;
195
+ if (queryDetails) {
196
+ console.log(logMessage);
197
+ console.log(`[HttpClient] ${queryDetails}`);
198
+ } else {
199
+ console.log(logMessage);
200
+ }
201
+ }
202
+ return result;
203
+ } catch (error) {
204
+ const duration = performance.now() - startTime;
205
+ if (typeof console !== "undefined") {
206
+ // For 404 errors on find-one calls, log at warn level (not error) since "not found" is expected
207
+ // Application layer handles these cases in try-catch blocks
208
+ const is404FindOne =
209
+ path === "/v1/rqlite/find-one" &&
210
+ error instanceof SDKError &&
211
+ error.httpStatus === 404;
212
+
213
+ if (is404FindOne) {
214
+ // Log as warning for visibility, but not as error since it's expected behavior
215
+ console.warn(
216
+ `[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(
217
+ 2
218
+ )}ms (expected for optional lookups)`
219
+ );
220
+ } else {
221
+ const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
222
+ 2
223
+ )}ms:`;
224
+ console.error(errorMessage, error);
225
+ if (queryDetails) {
226
+ console.error(`[HttpClient] ${queryDetails}`);
227
+ }
228
+ }
229
+ }
230
+ throw error;
151
231
  } finally {
152
232
  clearTimeout(timeoutId);
153
233
  }
@@ -156,7 +236,8 @@ export class HttpClient {
156
236
  private async requestWithRetry(
157
237
  url: string,
158
238
  options: RequestInit,
159
- attempt: number = 0
239
+ attempt: number = 0,
240
+ startTime?: number // Track start time for timing across retries
160
241
  ): Promise<any> {
161
242
  try {
162
243
  const response = await this.fetch(url, options);
@@ -185,7 +266,7 @@ export class HttpClient {
185
266
  await new Promise((resolve) =>
186
267
  setTimeout(resolve, this.retryDelayMs * (attempt + 1))
187
268
  );
188
- return this.requestWithRetry(url, options, attempt + 1);
269
+ return this.requestWithRetry(url, options, attempt + 1, startTime);
189
270
  }
190
271
  throw error;
191
272
  }
@@ -221,6 +302,104 @@ export class HttpClient {
221
302
  return this.request<T>("DELETE", path, options);
222
303
  }
223
304
 
305
+ /**
306
+ * Upload a file using multipart/form-data
307
+ * This is a special method for file uploads that bypasses JSON serialization
308
+ */
309
+ async uploadFile<T = any>(
310
+ path: string,
311
+ formData: FormData,
312
+ options?: {
313
+ timeout?: number;
314
+ }
315
+ ): Promise<T> {
316
+ const startTime = performance.now(); // Track upload start time
317
+ const url = new URL(this.baseURL + path);
318
+ const headers: Record<string, string> = {
319
+ ...this.getAuthHeaders(path),
320
+ // Don't set Content-Type - browser will set it with boundary
321
+ };
322
+
323
+ const controller = new AbortController();
324
+ const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
325
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
326
+
327
+ const fetchOptions: RequestInit = {
328
+ method: "POST",
329
+ headers,
330
+ body: formData,
331
+ signal: controller.signal,
332
+ };
333
+
334
+ try {
335
+ const result = await this.requestWithRetry(
336
+ url.toString(),
337
+ fetchOptions,
338
+ 0,
339
+ startTime
340
+ );
341
+ const duration = performance.now() - startTime;
342
+ if (typeof console !== "undefined") {
343
+ console.log(
344
+ `[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
345
+ 2
346
+ )}ms`
347
+ );
348
+ }
349
+ return result;
350
+ } catch (error) {
351
+ const duration = performance.now() - startTime;
352
+ if (typeof console !== "undefined") {
353
+ console.error(
354
+ `[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
355
+ 2
356
+ )}ms:`,
357
+ error
358
+ );
359
+ }
360
+ throw error;
361
+ } finally {
362
+ clearTimeout(timeoutId);
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Get a binary response (returns Response object for streaming)
368
+ */
369
+ async getBinary(path: string): Promise<Response> {
370
+ const url = new URL(this.baseURL + path);
371
+ const headers: Record<string, string> = {
372
+ ...this.getAuthHeaders(path),
373
+ };
374
+
375
+ const controller = new AbortController();
376
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
377
+
378
+ const fetchOptions: RequestInit = {
379
+ method: "GET",
380
+ headers,
381
+ signal: controller.signal,
382
+ };
383
+
384
+ try {
385
+ const response = await this.fetch(url.toString(), fetchOptions);
386
+ if (!response.ok) {
387
+ clearTimeout(timeoutId);
388
+ const error = await response.json().catch(() => ({
389
+ error: response.statusText,
390
+ }));
391
+ throw SDKError.fromResponse(response.status, error);
392
+ }
393
+ return response;
394
+ } catch (error) {
395
+ clearTimeout(timeoutId);
396
+ if (error instanceof SDKError) {
397
+ throw error;
398
+ }
399
+ throw error;
400
+ }
401
+ }
402
+
224
403
  getToken(): string | undefined {
225
404
  return this.getAuthToken();
226
405
  }
@@ -98,7 +98,9 @@ export class Repository<T extends Record<string, any>> {
98
98
  private buildInsertSql(entity: T): string {
99
99
  const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
100
100
  const placeholders = columns.map(() => "?").join(", ");
101
- return `INSERT INTO ${this.tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
101
+ return `INSERT INTO ${this.tableName} (${columns.join(
102
+ ", "
103
+ )}) VALUES (${placeholders})`;
102
104
  }
103
105
 
104
106
  private buildInsertArgs(entity: T): any[] {
@@ -111,7 +113,9 @@ export class Repository<T extends Record<string, any>> {
111
113
  const columns = Object.keys(entity)
112
114
  .filter((k) => entity[k] !== undefined && k !== this.primaryKey)
113
115
  .map((k) => `${k} = ?`);
114
- return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${this.primaryKey} = ?`;
116
+ return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${
117
+ this.primaryKey
118
+ } = ?`;
115
119
  }
116
120
 
117
121
  private buildUpdateArgs(entity: T): any[] {
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import { AuthClient } from "./auth/client";
3
3
  import { DBClient } from "./db/client";
4
4
  import { PubSubClient } from "./pubsub/client";
5
5
  import { NetworkClient } from "./network/client";
6
+ import { CacheClient } from "./cache/client";
7
+ import { StorageClient } from "./storage/client";
6
8
  import { WSClientConfig } from "./core/ws";
7
9
  import {
8
10
  StorageAdapter,
@@ -23,6 +25,8 @@ export interface Client {
23
25
  db: DBClient;
24
26
  pubsub: PubSubClient;
25
27
  network: NetworkClient;
28
+ cache: CacheClient;
29
+ storage: StorageClient;
26
30
  }
27
31
 
28
32
  export function createClient(config: ClientConfig): Client {
@@ -52,16 +56,19 @@ export function createClient(config: ClientConfig): Client {
52
56
  wsURL,
53
57
  });
54
58
  const network = new NetworkClient(httpClient);
59
+ const cache = new CacheClient(httpClient);
60
+ const storage = new StorageClient(httpClient);
55
61
 
56
62
  return {
57
63
  auth,
58
64
  db,
59
65
  pubsub,
60
66
  network,
67
+ cache,
68
+ storage,
61
69
  };
62
70
  }
63
71
 
64
- // Re-exports
65
72
  export { HttpClient } from "./core/http";
66
73
  export { WSClient } from "./core/ws";
67
74
  export { AuthClient } from "./auth/client";
@@ -70,6 +77,8 @@ export { QueryBuilder } from "./db/qb";
70
77
  export { Repository } from "./db/repository";
71
78
  export { PubSubClient, Subscription } from "./pubsub/client";
72
79
  export { NetworkClient } from "./network/client";
80
+ export { CacheClient } from "./cache/client";
81
+ export { StorageClient } from "./storage/client";
73
82
  export { SDKError } from "./errors";
74
83
  export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
75
84
  export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types";
@@ -86,3 +95,22 @@ export type {
86
95
  ProxyRequest,
87
96
  ProxyResponse,
88
97
  } from "./network/client";
98
+ export type {
99
+ CacheGetRequest,
100
+ CacheGetResponse,
101
+ CachePutRequest,
102
+ CachePutResponse,
103
+ CacheDeleteRequest,
104
+ CacheDeleteResponse,
105
+ CacheMultiGetRequest,
106
+ CacheMultiGetResponse,
107
+ CacheScanRequest,
108
+ CacheScanResponse,
109
+ CacheHealthResponse,
110
+ } from "./cache/client";
111
+ export type {
112
+ StorageUploadResponse,
113
+ StoragePinRequest,
114
+ StoragePinResponse,
115
+ StorageStatus,
116
+ } from "./storage/client";
@@ -0,0 +1,270 @@
1
+ import { HttpClient } from "../core/http";
2
+
3
+ export interface StorageUploadResponse {
4
+ cid: string;
5
+ name: string;
6
+ size: number;
7
+ }
8
+
9
+ export interface StoragePinRequest {
10
+ cid: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface StoragePinResponse {
15
+ cid: string;
16
+ name: string;
17
+ }
18
+
19
+ export interface StorageStatus {
20
+ cid: string;
21
+ name: string;
22
+ status: string; // "pinned", "pinning", "queued", "unpinned", "error"
23
+ replication_min: number;
24
+ replication_max: number;
25
+ replication_factor: number;
26
+ peers: string[];
27
+ error?: string;
28
+ }
29
+
30
+ export class StorageClient {
31
+ private httpClient: HttpClient;
32
+
33
+ constructor(httpClient: HttpClient) {
34
+ this.httpClient = httpClient;
35
+ }
36
+
37
+ /**
38
+ * Upload content to IPFS and optionally pin it.
39
+ * Supports both File objects (browser) and Buffer/ReadableStream (Node.js).
40
+ *
41
+ * @param file - File to upload (File, Blob, or Buffer)
42
+ * @param name - Optional filename
43
+ * @param options - Optional upload options
44
+ * @param options.pin - Whether to pin the content (default: true). Pinning happens asynchronously on the backend.
45
+ * @returns Upload result with CID
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Browser
50
+ * const fileInput = document.querySelector('input[type="file"]');
51
+ * const file = fileInput.files[0];
52
+ * const result = await client.storage.upload(file, file.name);
53
+ * console.log(result.cid);
54
+ *
55
+ * // Node.js
56
+ * const fs = require('fs');
57
+ * const fileBuffer = fs.readFileSync('image.jpg');
58
+ * const result = await client.storage.upload(fileBuffer, 'image.jpg', { pin: true });
59
+ * ```
60
+ */
61
+ async upload(
62
+ file: File | Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>,
63
+ name?: string,
64
+ options?: {
65
+ pin?: boolean;
66
+ }
67
+ ): Promise<StorageUploadResponse> {
68
+ // Create FormData for multipart upload
69
+ const formData = new FormData();
70
+
71
+ // Handle different input types
72
+ if (file instanceof File) {
73
+ formData.append("file", file);
74
+ } else if (file instanceof Blob) {
75
+ formData.append("file", file, name);
76
+ } else if (file instanceof ArrayBuffer) {
77
+ const blob = new Blob([file]);
78
+ formData.append("file", blob, name);
79
+ } else if (file instanceof Uint8Array) {
80
+ // Convert Uint8Array to ArrayBuffer for Blob constructor
81
+ const buffer = file.buffer.slice(
82
+ file.byteOffset,
83
+ file.byteOffset + file.byteLength
84
+ ) as ArrayBuffer;
85
+ const blob = new Blob([buffer], { type: "application/octet-stream" });
86
+ formData.append("file", blob, name);
87
+ } else if (file instanceof ReadableStream) {
88
+ // For ReadableStream, we need to read it into a blob first
89
+ // This is a limitation - in practice, pass File/Blob/Buffer
90
+ const chunks: ArrayBuffer[] = [];
91
+ const reader = file.getReader();
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ const buffer = value.buffer.slice(
96
+ value.byteOffset,
97
+ value.byteOffset + value.byteLength
98
+ ) as ArrayBuffer;
99
+ chunks.push(buffer);
100
+ }
101
+ const blob = new Blob(chunks);
102
+ formData.append("file", blob, name);
103
+ } else {
104
+ throw new Error(
105
+ "Unsupported file type. Use File, Blob, ArrayBuffer, Uint8Array, or ReadableStream."
106
+ );
107
+ }
108
+
109
+ // Add pin flag (default: true)
110
+ const shouldPin = options?.pin !== false; // Default to true
111
+ formData.append("pin", shouldPin ? "true" : "false");
112
+
113
+ return this.httpClient.uploadFile<StorageUploadResponse>(
114
+ "/v1/storage/upload",
115
+ formData,
116
+ { timeout: 300000 } // 5 minute timeout for large files
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Pin an existing CID
122
+ *
123
+ * @param cid - Content ID to pin
124
+ * @param name - Optional name for the pin
125
+ * @returns Pin result
126
+ */
127
+ async pin(cid: string, name?: string): Promise<StoragePinResponse> {
128
+ return this.httpClient.post<StoragePinResponse>("/v1/storage/pin", {
129
+ cid,
130
+ name,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Get the pin status for a CID
136
+ *
137
+ * @param cid - Content ID to check
138
+ * @returns Pin status information
139
+ */
140
+ async status(cid: string): Promise<StorageStatus> {
141
+ return this.httpClient.get<StorageStatus>(`/v1/storage/status/${cid}`);
142
+ }
143
+
144
+ /**
145
+ * Retrieve content from IPFS by CID
146
+ *
147
+ * @param cid - Content ID to retrieve
148
+ * @returns ReadableStream of the content
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * const stream = await client.storage.get(cid);
153
+ * const reader = stream.getReader();
154
+ * while (true) {
155
+ * const { done, value } = await reader.read();
156
+ * if (done) break;
157
+ * // Process chunk
158
+ * }
159
+ * ```
160
+ */
161
+ async get(cid: string): Promise<ReadableStream<Uint8Array>> {
162
+ // Retry logic for content retrieval - content may not be immediately available
163
+ // after upload due to eventual consistency in IPFS Cluster
164
+ // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
165
+ const maxAttempts = 8;
166
+ let lastError: Error | null = null;
167
+
168
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
169
+ try {
170
+ const response = await this.httpClient.getBinary(
171
+ `/v1/storage/get/${cid}`
172
+ );
173
+
174
+ if (!response.body) {
175
+ throw new Error("Response body is null");
176
+ }
177
+
178
+ return response.body;
179
+ } catch (error: any) {
180
+ lastError = error;
181
+
182
+ // Check if this is a 404 error (content not found)
183
+ const isNotFound =
184
+ error?.httpStatus === 404 ||
185
+ error?.message?.includes("not found") ||
186
+ error?.message?.includes("404");
187
+
188
+ // If it's not a 404 error, or this is the last attempt, give up
189
+ if (!isNotFound || attempt === maxAttempts) {
190
+ throw error;
191
+ }
192
+
193
+ // Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
194
+ // This gives up to ~12 seconds total wait time, covering typical pin completion
195
+ const backoffMs = attempt * 2500;
196
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
197
+ }
198
+ }
199
+
200
+ // This should never be reached, but TypeScript needs it
201
+ throw lastError || new Error("Failed to retrieve content");
202
+ }
203
+
204
+ /**
205
+ * Retrieve content from IPFS by CID and return the full Response object
206
+ * Useful when you need access to response headers (e.g., content-length)
207
+ *
208
+ * @param cid - Content ID to retrieve
209
+ * @returns Response object with body stream and headers
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * const response = await client.storage.getBinary(cid);
214
+ * const contentLength = response.headers.get('content-length');
215
+ * const reader = response.body.getReader();
216
+ * // ... read stream
217
+ * ```
218
+ */
219
+ async getBinary(cid: string): Promise<Response> {
220
+ // Retry logic for content retrieval - content may not be immediately available
221
+ // after upload due to eventual consistency in IPFS Cluster
222
+ // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
223
+ const maxAttempts = 8;
224
+ let lastError: Error | null = null;
225
+
226
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
227
+ try {
228
+ const response = await this.httpClient.getBinary(
229
+ `/v1/storage/get/${cid}`
230
+ );
231
+
232
+ if (!response) {
233
+ throw new Error("Response is null");
234
+ }
235
+
236
+ return response;
237
+ } catch (error: any) {
238
+ lastError = error;
239
+
240
+ // Check if this is a 404 error (content not found)
241
+ const isNotFound =
242
+ error?.httpStatus === 404 ||
243
+ error?.message?.includes("not found") ||
244
+ error?.message?.includes("404");
245
+
246
+ // If it's not a 404 error, or this is the last attempt, give up
247
+ if (!isNotFound || attempt === maxAttempts) {
248
+ throw error;
249
+ }
250
+
251
+ // Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
252
+ // This gives up to ~12 seconds total wait time, covering typical pin completion
253
+ const backoffMs = attempt * 2500;
254
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
255
+ }
256
+ }
257
+
258
+ // This should never be reached, but TypeScript needs it
259
+ throw lastError || new Error("Failed to retrieve content");
260
+ }
261
+
262
+ /**
263
+ * Unpin a CID
264
+ *
265
+ * @param cid - Content ID to unpin
266
+ */
267
+ async unpin(cid: string): Promise<void> {
268
+ await this.httpClient.delete(`/v1/storage/unpin/${cid}`);
269
+ }
270
+ }