soundcloud-api-ts 1.2.0 → 1.3.0

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
@@ -293,6 +293,57 @@ import { getSoundCloudWidgetUrl } from "soundcloud-api-ts";
293
293
  const widgetUrl = getSoundCloudWidgetUrl(trackId);
294
294
  ```
295
295
 
296
+ ## Error Handling
297
+
298
+ All API errors throw a `SoundCloudError` with structured properties:
299
+
300
+ ```ts
301
+ import { SoundCloudError } from "soundcloud-api-ts";
302
+
303
+ try {
304
+ await sc.tracks.getTrack(999);
305
+ } catch (err) {
306
+ if (err instanceof SoundCloudError) {
307
+ console.log(err.status); // 404
308
+ console.log(err.statusText); // "Not Found"
309
+ console.log(err.message); // "404 - Not Found" (from SC's response)
310
+ console.log(err.errorCode); // "invalid_client" (on auth errors)
311
+ console.log(err.errors); // ["404 - Not Found"] (individual error messages)
312
+ console.log(err.docsLink); // "https://developers.soundcloud.com/docs/api/explorer/open-api"
313
+ console.log(err.body); // full parsed response body
314
+
315
+ // Convenience getters
316
+ if (err.isNotFound) { /* handle 404 */ }
317
+ if (err.isRateLimited) { /* handle 429 */ }
318
+ if (err.isUnauthorized) { /* handle 401 */ }
319
+ if (err.isForbidden) { /* handle 403 */ }
320
+ if (err.isServerError) { /* handle 5xx */ }
321
+ }
322
+ }
323
+ ```
324
+
325
+ Error messages are parsed directly from SoundCloud's API response format, giving you the most useful message available.
326
+
327
+ ## Rate Limiting & Retries
328
+
329
+ The client automatically retries on **429 Too Many Requests** and **5xx Server Errors** with exponential backoff:
330
+
331
+ ```ts
332
+ const sc = new SoundCloudClient({
333
+ clientId: "...",
334
+ clientSecret: "...",
335
+ maxRetries: 3, // default: 3
336
+ retryBaseDelay: 1000, // default: 1000ms
337
+ onDebug: (msg) => console.log(msg), // optional retry logging
338
+ });
339
+ ```
340
+
341
+ - **429 responses** respect the `Retry-After` header when present
342
+ - **5xx responses** (500, 502, 503, 504) are retried with exponential backoff
343
+ - **4xx errors** (except 429) are NOT retried — they throw immediately
344
+ - **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
345
+ - Backoff formula: `baseDelay × 2^attempt` with jitter
346
+
296
347
  ## Requirements
297
348
 
298
349
  - Node.js 20+ (uses native `fetch`)
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var SoundCloudError = class extends Error {
5
+ /** HTTP status code */
6
+ status;
7
+ /** HTTP status text (e.g. "Unauthorized") */
8
+ statusText;
9
+ /** SC error code (e.g. "invalid_client") */
10
+ errorCode;
11
+ /** SC docs link */
12
+ docsLink;
13
+ /** Individual error messages from the errors array */
14
+ errors;
15
+ /** The full parsed response body */
16
+ body;
17
+ constructor(status, statusText, body) {
18
+ const msg = body?.message || body?.error_description || body?.error_code || body?.errors?.[0]?.error_message || body?.error || `${status} ${statusText}`;
19
+ super(msg);
20
+ this.name = "SoundCloudError";
21
+ this.status = status;
22
+ this.statusText = statusText;
23
+ this.errorCode = body?.error_code ?? void 0;
24
+ this.docsLink = body?.link ?? void 0;
25
+ this.errors = body?.errors?.map((e) => e.error_message).filter((m) => !!m) ?? [];
26
+ this.body = body;
27
+ }
28
+ /** True if status is 401 Unauthorized */
29
+ get isUnauthorized() {
30
+ return this.status === 401;
31
+ }
32
+ /** True if status is 403 Forbidden */
33
+ get isForbidden() {
34
+ return this.status === 403;
35
+ }
36
+ /** True if status is 404 Not Found */
37
+ get isNotFound() {
38
+ return this.status === 404;
39
+ }
40
+ /** True if status is 429 Too Many Requests */
41
+ get isRateLimited() {
42
+ return this.status === 429;
43
+ }
44
+ /** True if status is 5xx server error */
45
+ get isServerError() {
46
+ return this.status >= 500 && this.status < 600;
47
+ }
48
+ };
49
+
50
+ exports.SoundCloudError = SoundCloudError;
51
+ //# sourceMappingURL=chunk-34DWTDWF.js.map
52
+ //# sourceMappingURL=chunk-34DWTDWF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";;;AAgCO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA;AAAA,EAEhC,MAAA;AAAA;AAAA,EAEA,UAAA;AAAA;AAAA,EAEA,SAAA;AAAA;AAAA,EAEA,QAAA;AAAA;AAAA,EAEA,MAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,MAAA,EAAgB,UAAA,EAAoB,IAAA,EAA4B;AAE1E,IAAA,MAAM,MACJ,IAAA,EAAM,OAAA,IACN,IAAA,EAAM,iBAAA,IACN,MAAM,UAAA,IACN,IAAA,EAAM,MAAA,GAAS,CAAC,GAAG,aAAA,IACnB,IAAA,EAAM,SACN,CAAA,EAAG,MAAM,IAAI,UAAU,CAAA,CAAA;AAEzB,IAAA,KAAA,CAAM,GAAG,CAAA;AACT,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,UAAA,GAAa,UAAA;AAClB,IAAA,IAAA,CAAK,SAAA,GAAY,MAAM,UAAA,IAAc,MAAA;AACrC,IAAA,IAAA,CAAK,QAAA,GAAW,MAAM,IAAA,IAAQ,MAAA;AAC9B,IAAA,IAAA,CAAK,SACH,IAAA,EAAM,MAAA,EACF,GAAA,CAAI,CAAC,MAAM,CAAA,CAAE,aAAa,CAAA,CAC3B,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,KAAK,EAAC;AACzC,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,cAAA,GAA0B;AAC5B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAuB;AACzB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,UAAA,GAAsB;AACxB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAA,IAAU,GAAA,IAAO,IAAA,CAAK,MAAA,GAAS,GAAA;AAAA,EAC7C;AACF","file":"chunk-34DWTDWF.js","sourcesContent":["/**\n * SoundCloud API error response shape.\n * Based on actual SC API responses, e.g.:\n * {\n * \"code\": 401,\n * \"message\": \"invalid_client\",\n * \"link\": \"https://developers.soundcloud.com/docs/api/explorer/open-api\",\n * \"status\": \"401 - Unauthorized\",\n * \"errors\": [{\"error_message\": \"invalid_client\"}],\n * \"error\": null,\n * \"error_code\": \"invalid_client\"\n * }\n */\nexport interface SoundCloudErrorBody {\n /** HTTP status code */\n code?: number;\n /** Error message from SC (e.g. \"invalid_client\", \"404 - Not Found\") */\n message?: string;\n /** SC status string (e.g. \"401 - Unauthorized\") */\n status?: string;\n /** Link to SC API docs */\n link?: string;\n /** Array of individual errors */\n errors?: Array<{ error_message?: string }>;\n /** Error field — typically null in SC responses */\n error?: string | null;\n /** Error code (e.g. \"invalid_client\") */\n error_code?: string;\n /** OAuth error_description (used in /oauth2/token errors) */\n error_description?: string;\n}\n\nexport class SoundCloudError extends Error {\n /** HTTP status code */\n readonly status: number;\n /** HTTP status text (e.g. \"Unauthorized\") */\n readonly statusText: string;\n /** SC error code (e.g. \"invalid_client\") */\n readonly errorCode?: string;\n /** SC docs link */\n readonly docsLink?: string;\n /** Individual error messages from the errors array */\n readonly errors: string[];\n /** The full parsed response body */\n readonly body?: SoundCloudErrorBody;\n\n constructor(status: number, statusText: string, body?: SoundCloudErrorBody) {\n // Build the most useful message we can from SC's response\n const msg =\n body?.message ||\n body?.error_description ||\n body?.error_code ||\n body?.errors?.[0]?.error_message ||\n body?.error ||\n `${status} ${statusText}`;\n\n super(msg);\n this.name = \"SoundCloudError\";\n this.status = status;\n this.statusText = statusText;\n this.errorCode = body?.error_code ?? undefined;\n this.docsLink = body?.link ?? undefined;\n this.errors =\n body?.errors\n ?.map((e) => e.error_message)\n .filter((m): m is string => !!m) ?? [];\n this.body = body;\n }\n\n /** True if status is 401 Unauthorized */\n get isUnauthorized(): boolean {\n return this.status === 401;\n }\n\n /** True if status is 403 Forbidden */\n get isForbidden(): boolean {\n return this.status === 403;\n }\n\n /** True if status is 404 Not Found */\n get isNotFound(): boolean {\n return this.status === 404;\n }\n\n /** True if status is 429 Too Many Requests */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /** True if status is 5xx server error */\n get isServerError(): boolean {\n return this.status >= 500 && this.status < 600;\n }\n}\n"]}
@@ -0,0 +1,50 @@
1
+ // src/errors.ts
2
+ var SoundCloudError = class extends Error {
3
+ /** HTTP status code */
4
+ status;
5
+ /** HTTP status text (e.g. "Unauthorized") */
6
+ statusText;
7
+ /** SC error code (e.g. "invalid_client") */
8
+ errorCode;
9
+ /** SC docs link */
10
+ docsLink;
11
+ /** Individual error messages from the errors array */
12
+ errors;
13
+ /** The full parsed response body */
14
+ body;
15
+ constructor(status, statusText, body) {
16
+ const msg = body?.message || body?.error_description || body?.error_code || body?.errors?.[0]?.error_message || body?.error || `${status} ${statusText}`;
17
+ super(msg);
18
+ this.name = "SoundCloudError";
19
+ this.status = status;
20
+ this.statusText = statusText;
21
+ this.errorCode = body?.error_code ?? void 0;
22
+ this.docsLink = body?.link ?? void 0;
23
+ this.errors = body?.errors?.map((e) => e.error_message).filter((m) => !!m) ?? [];
24
+ this.body = body;
25
+ }
26
+ /** True if status is 401 Unauthorized */
27
+ get isUnauthorized() {
28
+ return this.status === 401;
29
+ }
30
+ /** True if status is 403 Forbidden */
31
+ get isForbidden() {
32
+ return this.status === 403;
33
+ }
34
+ /** True if status is 404 Not Found */
35
+ get isNotFound() {
36
+ return this.status === 404;
37
+ }
38
+ /** True if status is 429 Too Many Requests */
39
+ get isRateLimited() {
40
+ return this.status === 429;
41
+ }
42
+ /** True if status is 5xx server error */
43
+ get isServerError() {
44
+ return this.status >= 500 && this.status < 600;
45
+ }
46
+ };
47
+
48
+ export { SoundCloudError };
49
+ //# sourceMappingURL=chunk-GKNBLKPB.mjs.map
50
+ //# sourceMappingURL=chunk-GKNBLKPB.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";AAgCO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA;AAAA,EAEhC,MAAA;AAAA;AAAA,EAEA,UAAA;AAAA;AAAA,EAEA,SAAA;AAAA;AAAA,EAEA,QAAA;AAAA;AAAA,EAEA,MAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,MAAA,EAAgB,UAAA,EAAoB,IAAA,EAA4B;AAE1E,IAAA,MAAM,MACJ,IAAA,EAAM,OAAA,IACN,IAAA,EAAM,iBAAA,IACN,MAAM,UAAA,IACN,IAAA,EAAM,MAAA,GAAS,CAAC,GAAG,aAAA,IACnB,IAAA,EAAM,SACN,CAAA,EAAG,MAAM,IAAI,UAAU,CAAA,CAAA;AAEzB,IAAA,KAAA,CAAM,GAAG,CAAA;AACT,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,UAAA,GAAa,UAAA;AAClB,IAAA,IAAA,CAAK,SAAA,GAAY,MAAM,UAAA,IAAc,MAAA;AACrC,IAAA,IAAA,CAAK,QAAA,GAAW,MAAM,IAAA,IAAQ,MAAA;AAC9B,IAAA,IAAA,CAAK,SACH,IAAA,EAAM,MAAA,EACF,GAAA,CAAI,CAAC,MAAM,CAAA,CAAE,aAAa,CAAA,CAC3B,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,KAAK,EAAC;AACzC,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,cAAA,GAA0B;AAC5B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAuB;AACzB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,UAAA,GAAsB;AACxB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAA,IAAU,GAAA,IAAO,IAAA,CAAK,MAAA,GAAS,GAAA;AAAA,EAC7C;AACF","file":"chunk-GKNBLKPB.mjs","sourcesContent":["/**\n * SoundCloud API error response shape.\n * Based on actual SC API responses, e.g.:\n * {\n * \"code\": 401,\n * \"message\": \"invalid_client\",\n * \"link\": \"https://developers.soundcloud.com/docs/api/explorer/open-api\",\n * \"status\": \"401 - Unauthorized\",\n * \"errors\": [{\"error_message\": \"invalid_client\"}],\n * \"error\": null,\n * \"error_code\": \"invalid_client\"\n * }\n */\nexport interface SoundCloudErrorBody {\n /** HTTP status code */\n code?: number;\n /** Error message from SC (e.g. \"invalid_client\", \"404 - Not Found\") */\n message?: string;\n /** SC status string (e.g. \"401 - Unauthorized\") */\n status?: string;\n /** Link to SC API docs */\n link?: string;\n /** Array of individual errors */\n errors?: Array<{ error_message?: string }>;\n /** Error field — typically null in SC responses */\n error?: string | null;\n /** Error code (e.g. \"invalid_client\") */\n error_code?: string;\n /** OAuth error_description (used in /oauth2/token errors) */\n error_description?: string;\n}\n\nexport class SoundCloudError extends Error {\n /** HTTP status code */\n readonly status: number;\n /** HTTP status text (e.g. \"Unauthorized\") */\n readonly statusText: string;\n /** SC error code (e.g. \"invalid_client\") */\n readonly errorCode?: string;\n /** SC docs link */\n readonly docsLink?: string;\n /** Individual error messages from the errors array */\n readonly errors: string[];\n /** The full parsed response body */\n readonly body?: SoundCloudErrorBody;\n\n constructor(status: number, statusText: string, body?: SoundCloudErrorBody) {\n // Build the most useful message we can from SC's response\n const msg =\n body?.message ||\n body?.error_description ||\n body?.error_code ||\n body?.errors?.[0]?.error_message ||\n body?.error ||\n `${status} ${statusText}`;\n\n super(msg);\n this.name = \"SoundCloudError\";\n this.status = status;\n this.statusText = statusText;\n this.errorCode = body?.error_code ?? undefined;\n this.docsLink = body?.link ?? undefined;\n this.errors =\n body?.errors\n ?.map((e) => e.error_message)\n .filter((m): m is string => !!m) ?? [];\n this.body = body;\n }\n\n /** True if status is 401 Unauthorized */\n get isUnauthorized(): boolean {\n return this.status === 401;\n }\n\n /** True if status is 403 Forbidden */\n get isForbidden(): boolean {\n return this.status === 403;\n }\n\n /** True if status is 404 Not Found */\n get isNotFound(): boolean {\n return this.status === 404;\n }\n\n /** True if status is 429 Too Many Requests */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /** True if status is 5xx server error */\n get isServerError(): boolean {\n return this.status >= 500 && this.status < 600;\n }\n}\n"]}
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SoundCloudTrack, SoundCloudPlaylist, SoundCloudToken, SoundCloudPaginatedResponse, SoundCloudMe, SoundCloudActivitiesResponse, SoundCloudUser, SoundCloudWebProfile, SoundCloudStreams, SoundCloudComment } from './types/index.mjs';
2
- export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.mjs';
2
+ export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudError, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.mjs';
3
3
 
4
4
  interface RequestOptions {
5
5
  path: string;
@@ -8,6 +8,14 @@ interface RequestOptions {
8
8
  body?: Record<string, unknown> | FormData | URLSearchParams;
9
9
  contentType?: string;
10
10
  }
11
+ interface RetryConfig {
12
+ /** Max retries on 429/5xx (default: 3) */
13
+ maxRetries: number;
14
+ /** Base delay in ms for exponential backoff (default: 1000) */
15
+ retryBaseDelay: number;
16
+ /** Optional debug logger */
17
+ onDebug?: (message: string) => void;
18
+ }
11
19
  interface AutoRefreshContext {
12
20
  getToken: () => string | undefined;
13
21
  onTokenRefresh?: () => Promise<{
@@ -15,6 +23,7 @@ interface AutoRefreshContext {
15
23
  refresh_token?: string;
16
24
  }>;
17
25
  setToken: (accessToken: string, refreshToken?: string) => void;
26
+ retry?: RetryConfig;
18
27
  }
19
28
  /**
20
29
  * Make a request to the SoundCloud API using native fetch.
@@ -25,7 +34,7 @@ declare function scFetch<T>(options: RequestOptions, refreshCtx?: AutoRefreshCon
25
34
  * Fetch an absolute URL (e.g. next_href from paginated responses).
26
35
  * Adds OAuth token if provided.
27
36
  */
28
- declare function scFetchUrl<T>(url: string, token?: string): Promise<T>;
37
+ declare function scFetchUrl<T>(url: string, token?: string, retryConfig?: RetryConfig): Promise<T>;
29
38
 
30
39
  interface UpdateTrackParams {
31
40
  title?: string;
@@ -99,6 +108,12 @@ interface SoundCloudClientConfig {
99
108
  redirectUri?: string;
100
109
  /** Called automatically when a request returns 401. Return new tokens to retry. */
101
110
  onTokenRefresh?: (client: SoundCloudClient) => Promise<SoundCloudToken>;
111
+ /** Max retries on 429/5xx (default: 3) */
112
+ maxRetries?: number;
113
+ /** Base delay in ms for exponential backoff (default: 1000) */
114
+ retryBaseDelay?: number;
115
+ /** Optional debug logger for retry attempts, etc. */
116
+ onDebug?: (message: string) => void;
102
117
  }
103
118
  /** Optional token override, passed as the last parameter to client methods. */
104
119
  interface TokenOption {
@@ -487,4 +502,4 @@ declare const unrepostPlaylist: (token: string, playlistId: string | number) =>
487
502
  */
488
503
  declare const getSoundCloudWidgetUrl: (trackId: string | number) => string;
489
504
 
490
- export { type CreatePlaylistParams, type RequestOptions, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
505
+ export { type CreatePlaylistParams, type RequestOptions, type RetryConfig, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SoundCloudTrack, SoundCloudPlaylist, SoundCloudToken, SoundCloudPaginatedResponse, SoundCloudMe, SoundCloudActivitiesResponse, SoundCloudUser, SoundCloudWebProfile, SoundCloudStreams, SoundCloudComment } from './types/index.js';
2
- export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.js';
2
+ export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudError, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.js';
3
3
 
4
4
  interface RequestOptions {
5
5
  path: string;
@@ -8,6 +8,14 @@ interface RequestOptions {
8
8
  body?: Record<string, unknown> | FormData | URLSearchParams;
9
9
  contentType?: string;
10
10
  }
11
+ interface RetryConfig {
12
+ /** Max retries on 429/5xx (default: 3) */
13
+ maxRetries: number;
14
+ /** Base delay in ms for exponential backoff (default: 1000) */
15
+ retryBaseDelay: number;
16
+ /** Optional debug logger */
17
+ onDebug?: (message: string) => void;
18
+ }
11
19
  interface AutoRefreshContext {
12
20
  getToken: () => string | undefined;
13
21
  onTokenRefresh?: () => Promise<{
@@ -15,6 +23,7 @@ interface AutoRefreshContext {
15
23
  refresh_token?: string;
16
24
  }>;
17
25
  setToken: (accessToken: string, refreshToken?: string) => void;
26
+ retry?: RetryConfig;
18
27
  }
19
28
  /**
20
29
  * Make a request to the SoundCloud API using native fetch.
@@ -25,7 +34,7 @@ declare function scFetch<T>(options: RequestOptions, refreshCtx?: AutoRefreshCon
25
34
  * Fetch an absolute URL (e.g. next_href from paginated responses).
26
35
  * Adds OAuth token if provided.
27
36
  */
28
- declare function scFetchUrl<T>(url: string, token?: string): Promise<T>;
37
+ declare function scFetchUrl<T>(url: string, token?: string, retryConfig?: RetryConfig): Promise<T>;
29
38
 
30
39
  interface UpdateTrackParams {
31
40
  title?: string;
@@ -99,6 +108,12 @@ interface SoundCloudClientConfig {
99
108
  redirectUri?: string;
100
109
  /** Called automatically when a request returns 401. Return new tokens to retry. */
101
110
  onTokenRefresh?: (client: SoundCloudClient) => Promise<SoundCloudToken>;
111
+ /** Max retries on 429/5xx (default: 3) */
112
+ maxRetries?: number;
113
+ /** Base delay in ms for exponential backoff (default: 1000) */
114
+ retryBaseDelay?: number;
115
+ /** Optional debug logger for retry attempts, etc. */
116
+ onDebug?: (message: string) => void;
102
117
  }
103
118
  /** Optional token override, passed as the last parameter to client methods. */
104
119
  interface TokenOption {
@@ -487,4 +502,4 @@ declare const unrepostPlaylist: (token: string, playlistId: string | number) =>
487
502
  */
488
503
  declare const getSoundCloudWidgetUrl: (trackId: string | number) => string;
489
504
 
490
- export { type CreatePlaylistParams, type RequestOptions, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
505
+ export { type CreatePlaylistParams, type RequestOptions, type RetryConfig, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
package/dist/index.js CHANGED
@@ -1,8 +1,36 @@
1
1
  'use strict';
2
2
 
3
+ var chunk34DWTDWF_js = require('./chunk-34DWTDWF.js');
4
+
3
5
  // src/client/http.ts
4
6
  var BASE_URL = "https://api.soundcloud.com";
7
+ var DEFAULT_RETRY = { maxRetries: 3, retryBaseDelay: 1e3 };
8
+ function delay(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function isRetryable(status) {
12
+ return status === 429 || status >= 500 && status <= 599;
13
+ }
14
+ function getRetryDelay(response, attempt, config) {
15
+ if (response.status === 429) {
16
+ const retryAfter = response.headers.get("retry-after");
17
+ if (retryAfter) {
18
+ const seconds = Number(retryAfter);
19
+ if (!Number.isNaN(seconds)) return seconds * 1e3;
20
+ }
21
+ }
22
+ const base = config.retryBaseDelay * Math.pow(2, attempt);
23
+ return base + Math.random() * base * 0.1;
24
+ }
25
+ async function parseErrorBody(response) {
26
+ try {
27
+ return await response.json();
28
+ } catch {
29
+ return void 0;
30
+ }
31
+ }
5
32
  async function scFetch(options, refreshCtx) {
33
+ const retryConfig = refreshCtx?.retry ?? DEFAULT_RETRY;
6
34
  const execute = async (tokenOverride) => {
7
35
  const url = `${BASE_URL}${options.path}`;
8
36
  const headers = {
@@ -26,32 +54,44 @@ async function scFetch(options, refreshCtx) {
26
54
  } else if (options.contentType) {
27
55
  headers["Content-Type"] = options.contentType;
28
56
  }
29
- const response = await fetch(url, {
30
- method: options.method,
31
- headers,
32
- body: fetchBody,
33
- redirect: "manual"
34
- });
35
- if (response.status === 302) {
36
- const location = response.headers.get("location");
37
- if (location) {
38
- return location;
57
+ let lastResponse;
58
+ for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
59
+ const response = await fetch(url, {
60
+ method: options.method,
61
+ headers,
62
+ body: fetchBody,
63
+ redirect: "manual"
64
+ });
65
+ if (response.status === 302) {
66
+ const location = response.headers.get("location");
67
+ if (location) return location;
68
+ }
69
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
70
+ return void 0;
71
+ }
72
+ if (response.ok) {
73
+ return response.json();
74
+ }
75
+ if (!isRetryable(response.status)) {
76
+ const body2 = await parseErrorBody(response);
77
+ throw new chunk34DWTDWF_js.SoundCloudError(response.status, response.statusText, body2);
78
+ }
79
+ lastResponse = response;
80
+ if (attempt < retryConfig.maxRetries) {
81
+ const delayMs = getRetryDelay(response, attempt, retryConfig);
82
+ retryConfig.onDebug?.(
83
+ `Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
84
+ );
85
+ await delay(delayMs);
39
86
  }
40
87
  }
41
- if (response.status === 204 || response.headers.get("content-length") === "0") {
42
- return void 0;
43
- }
44
- if (!response.ok) {
45
- throw new Error(
46
- `SoundCloud API error: ${response.status} ${response.statusText}`
47
- );
48
- }
49
- return response.json();
88
+ const body = await parseErrorBody(lastResponse);
89
+ throw new chunk34DWTDWF_js.SoundCloudError(lastResponse.status, lastResponse.statusText, body);
50
90
  };
51
91
  try {
52
92
  return await execute();
53
93
  } catch (err) {
54
- if (refreshCtx?.onTokenRefresh && err instanceof Error && err.message.includes("401")) {
94
+ if (refreshCtx?.onTokenRefresh && err instanceof chunk34DWTDWF_js.SoundCloudError && err.status === 401) {
55
95
  const newToken = await refreshCtx.onTokenRefresh();
56
96
  refreshCtx.setToken(newToken.access_token, newToken.refresh_token);
57
97
  return execute(newToken.access_token);
@@ -59,21 +99,38 @@ async function scFetch(options, refreshCtx) {
59
99
  throw err;
60
100
  }
61
101
  }
62
- async function scFetchUrl(url, token) {
102
+ async function scFetchUrl(url, token, retryConfig) {
103
+ const config = retryConfig ?? DEFAULT_RETRY;
63
104
  const headers = { Accept: "application/json" };
64
105
  if (token) headers["Authorization"] = `OAuth ${token}`;
65
- const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
66
- if (response.status === 302) {
67
- const location = response.headers.get("location");
68
- if (location) return location;
69
- }
70
- if (response.status === 204 || response.headers.get("content-length") === "0") {
71
- return void 0;
72
- }
73
- if (!response.ok) {
74
- throw new Error(`SoundCloud API error: ${response.status} ${response.statusText}`);
106
+ let lastResponse;
107
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
108
+ const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
109
+ if (response.status === 302) {
110
+ const location = response.headers.get("location");
111
+ if (location) return location;
112
+ }
113
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
114
+ return void 0;
115
+ }
116
+ if (response.ok) {
117
+ return response.json();
118
+ }
119
+ if (!isRetryable(response.status)) {
120
+ const body2 = await parseErrorBody(response);
121
+ throw new chunk34DWTDWF_js.SoundCloudError(response.status, response.statusText, body2);
122
+ }
123
+ lastResponse = response;
124
+ if (attempt < config.maxRetries) {
125
+ const delayMs = getRetryDelay(response, attempt, config);
126
+ config.onDebug?.(
127
+ `Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
128
+ );
129
+ await delay(delayMs);
130
+ }
75
131
  }
76
- return response.json();
132
+ const body = await parseErrorBody(lastResponse);
133
+ throw new chunk34DWTDWF_js.SoundCloudError(lastResponse.status, lastResponse.statusText, body);
77
134
  }
78
135
 
79
136
  // src/client/paginate.ts
@@ -126,14 +183,24 @@ exports.SoundCloudClient = class _SoundCloudClient {
126
183
  constructor(config) {
127
184
  this.config = config;
128
185
  const getToken = () => this._accessToken;
186
+ const retryConfig = {
187
+ maxRetries: config.maxRetries ?? 3,
188
+ retryBaseDelay: config.retryBaseDelay ?? 1e3,
189
+ onDebug: config.onDebug
190
+ };
129
191
  const refreshCtx = config.onTokenRefresh ? {
130
192
  getToken,
131
193
  onTokenRefresh: async () => {
132
194
  const result = await config.onTokenRefresh(this);
133
195
  return result;
134
196
  },
135
- setToken: (a, r) => this.setToken(a, r)
136
- } : void 0;
197
+ setToken: (a, r) => this.setToken(a, r),
198
+ retry: retryConfig
199
+ } : {
200
+ getToken,
201
+ setToken: (a, r) => this.setToken(a, r),
202
+ retry: retryConfig
203
+ };
137
204
  this.auth = new _SoundCloudClient.Auth(this.config);
138
205
  this.me = new _SoundCloudClient.Me(getToken, refreshCtx);
139
206
  this.users = new _SoundCloudClient.Users(getToken, refreshCtx);
@@ -935,6 +1002,10 @@ var unrepostPlaylist = async (token, playlistId) => {
935
1002
  // src/utils/widget.ts
936
1003
  var getSoundCloudWidgetUrl = (trackId) => `https%3A//api.soundcloud.com/tracks/${trackId}&show_teaser=false&color=%2300a99d&inverse=false&show_user=false&sharing=false&buying=false&liking=false&show_artwork=false&show_name=false`;
937
1004
 
1005
+ Object.defineProperty(exports, "SoundCloudError", {
1006
+ enumerable: true,
1007
+ get: function () { return chunk34DWTDWF_js.SoundCloudError; }
1008
+ });
938
1009
  exports.createPlaylist = createPlaylist;
939
1010
  exports.createTrackComment = createTrackComment;
940
1011
  exports.deletePlaylist = deletePlaylist;