gerdur-core 2.3.0 → 2.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 - 2026-08-31
4
+
5
+ Phase 3.3 — deterministic retries and typed errors.
6
+
7
+ ### Fixed
8
+
9
+ - **The gateway retry loop had no cap** on `error.code === 4`,
10
+ `NEED_API_AUTH_REQUIRED`, or `GATEWAY_ERROR` — a failing endpoint could spin
11
+ forever. `requestWithRetry` is now a bounded loop: per-class attempt caps
12
+ (`code 4` ×6, re-auth ×3, token refresh ×15), full-jittered exponential
13
+ backoff, and a 30 s wall-clock deadline. On exhaustion it throws.
14
+
15
+ ### Added
16
+
17
+ - **`DeezerError`** (`extends Error`) — thrown for gateway / media-API failures
18
+ instead of `new Error(Object.entries(error).join(', '))`. Carries `code`,
19
+ `keys`, `retryable`, and the raw `payload`. `message` stays human-readable.
20
+ - **`RETRY_POLICY`** — the exported, inspectable retry configuration.
21
+
22
+ ### Changed
23
+
24
+ - Gateway helpers (`request`, `requestLight`, `requestGet`, `requestPublicApi`)
25
+ and `resolveDownloadUrls` now throw `DeezerError`. `message` text changed shape
26
+ (`"KEY: value"` rather than `"KEY,value"`); read `err.code` / `err.keys`
27
+ instead of matching the string.
28
+
3
29
  ## 2.3.0 - 2026-08-31
4
30
 
5
31
  Phase 2.1 — all the formats, and previews. Additive; the `1 / 3 / 9` path is
package/README.md CHANGED
@@ -82,7 +82,20 @@ if (model.lyricsSynced) fs.writeFileSync(track.SNG_TITLE + '.lrc', model.lyricsS
82
82
 
83
83
  ## Methods
84
84
 
85
- All method returns `Object` or throws `Error`. Make sure to catch error on your side.
85
+ Every method returns an `Object` or throws. Gateway / media-API failures throw a
86
+ **`DeezerError`** (`extends Error`) with:
87
+
88
+ | Field | |
89
+ | :--- | :--- |
90
+ | `code` | Deezer's numeric error code, when it sent one (e.g. `4`, `800`) |
91
+ | `keys` | the gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` |
92
+ | `retryable` | whether a retry could plausibly succeed |
93
+ | `payload` | the raw error object |
94
+
95
+ Retries are bounded — `RETRY_POLICY` (exported) sets per-class attempt caps and a
96
+ 30 s wall-clock deadline, so a persistently failing endpoint surfaces a
97
+ `DeezerError` instead of spinning. `GeoBlocked`, `WrongLicense` and
98
+ `ExpiredTrackToken` are still thrown as their own types from the download path.
86
99
 
87
100
  ### `.initDeezerApi(arl_cookie);`
88
101
 
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.requestPublicApi = exports.requestGet = exports.requestLight = exports.request = void 0;
7
7
  const request_1 = __importDefault(require("../lib/request"));
8
+ const errors_1 = require("../lib/errors");
8
9
  const cache_1 = __importDefault(require("./cache"));
9
10
  /**
10
11
  * In-flight request coalescing (single-flight).
@@ -47,11 +48,11 @@ const request = async (body, method) => {
47
48
  const cacheKey = method + ':' + Object.entries(body).join(':');
48
49
  return coalesce(cacheKey, async () => {
49
50
  const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
50
- if (Object.keys(results).length > 0) {
51
+ if (results && Object.keys(results).length > 0) {
51
52
  cache_1.default.set(cacheKey, results);
52
53
  return results;
53
54
  }
54
- throw new Error(Object.entries(error).join(', '));
55
+ throw new errors_1.DeezerError(error);
55
56
  });
56
57
  };
57
58
  exports.request = request;
@@ -66,11 +67,11 @@ const requestLight = async (body, method) => {
66
67
  const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
67
68
  params: { method, api_version: '1.0' },
68
69
  });
69
- if (Object.keys(results).length > 0) {
70
+ if (results && Object.keys(results).length > 0) {
70
71
  cache_1.default.set(cacheKey, results);
71
72
  return results;
72
73
  }
73
- throw new Error(Object.entries(error).join(', '));
74
+ throw new errors_1.DeezerError(error);
74
75
  });
75
76
  };
76
77
  exports.requestLight = requestLight;
@@ -83,11 +84,11 @@ const requestGet = async (method, params = {}, key = 'get_request') => {
83
84
  const cacheKey = method + key;
84
85
  return coalesce(cacheKey, async () => {
85
86
  const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
86
- if (Object.keys(results).length > 0) {
87
+ if (results && Object.keys(results).length > 0) {
87
88
  cache_1.default.set(cacheKey, results);
88
89
  return results;
89
90
  }
90
- throw new Error(Object.entries(error).join(', '));
91
+ throw new errors_1.DeezerError(error);
91
92
  });
92
93
  };
93
94
  exports.requestGet = requestGet;
@@ -99,8 +100,7 @@ const requestPublicApi = async (slug) => {
99
100
  return coalesce(slug, async () => {
100
101
  const { data } = await request_1.default.get('https://api.deezer.com' + slug);
101
102
  if (data.error) {
102
- const errorMessage = Object.entries(data.error).join(', ');
103
- throw new Error(errorMessage);
103
+ throw new errors_1.DeezerError(data.error);
104
104
  }
105
105
  cache_1.default.set(slug, data);
106
106
  return data;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { initDeezerApi } from './lib/request';
1
+ export { initDeezerApi, RETRY_POLICY } from './lib/request';
2
+ export { DeezerError } from './lib/errors';
3
+ export type { DeezerErrorPayload } from './lib/errors';
2
4
  export * from './api';
3
5
  export * from './converter';
4
6
  export * from './lib/decrypt';
package/dist/index.js CHANGED
@@ -14,9 +14,12 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.initDeezerApi = void 0;
17
+ exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.RETRY_POLICY = exports.initDeezerApi = void 0;
18
18
  var request_1 = require("./lib/request");
19
19
  Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return request_1.initDeezerApi; } });
20
+ Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function () { return request_1.RETRY_POLICY; } });
21
+ var errors_1 = require("./lib/errors");
22
+ Object.defineProperty(exports, "DeezerError", { enumerable: true, get: function () { return errors_1.DeezerError; } });
20
23
  __exportStar(require("./api"), exports);
21
24
  __exportStar(require("./converter"), exports);
22
25
  __exportStar(require("./lib/decrypt"), exports);
@@ -0,0 +1,27 @@
1
+ /** The raw `error` object a Deezer gateway response carries. */
2
+ export type DeezerErrorPayload = Record<string, unknown>;
3
+ /**
4
+ * A structured error from the Deezer gateway or media API — replaces the old
5
+ * `new Error(Object.entries(error).join(', '))`. Carries the numeric `code`, the
6
+ * gateway error `keys`, a `retryable` hint, and the raw `payload`.
7
+ *
8
+ * The `message` is still human-readable (`"VALID_TOKEN_REQUIRED: …"`), so code
9
+ * that only reads `err.message` keeps working — but prefer `err.code` /
10
+ * `err.keys` / `err.retryable`.
11
+ */
12
+ export declare class DeezerError extends Error {
13
+ /** `error.code` when Deezer sent a numeric one */
14
+ readonly code?: number;
15
+ /** the gateway error keys, e.g. `['VALID_TOKEN_REQUIRED']`, `['DATA_ERROR']` */
16
+ readonly keys: string[];
17
+ /** whether a retry could plausibly succeed */
18
+ readonly retryable: boolean;
19
+ /** the raw error payload */
20
+ readonly payload: DeezerErrorPayload;
21
+ constructor(payload: DeezerErrorPayload | null | undefined, opts?: {
22
+ retryable?: boolean;
23
+ message?: string;
24
+ });
25
+ /** Whether a `code` / key set is worth retrying. */
26
+ static retryable(code: number | undefined, keys: string[]): boolean;
27
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DeezerError = void 0;
4
+ /** Gateway error keys where retrying (after a token refresh / re-auth) can help. */
5
+ const RETRYABLE_KEYS = new Set(['NEED_API_AUTH_REQUIRED', 'GATEWAY_ERROR', 'VALID_TOKEN_REQUIRED']);
6
+ /**
7
+ * A structured error from the Deezer gateway or media API — replaces the old
8
+ * `new Error(Object.entries(error).join(', '))`. Carries the numeric `code`, the
9
+ * gateway error `keys`, a `retryable` hint, and the raw `payload`.
10
+ *
11
+ * The `message` is still human-readable (`"VALID_TOKEN_REQUIRED: …"`), so code
12
+ * that only reads `err.message` keeps working — but prefer `err.code` /
13
+ * `err.keys` / `err.retryable`.
14
+ */
15
+ class DeezerError extends Error {
16
+ constructor(payload, opts = {}) {
17
+ var _a, _b;
18
+ const p = payload && typeof payload === 'object' ? payload : {};
19
+ const keys = Object.keys(p);
20
+ const rawCode = p.code;
21
+ const code = typeof rawCode === 'number' ? rawCode : undefined;
22
+ super((_a = opts.message) !== null && _a !== void 0 ? _a : describe(p, keys));
23
+ this.name = 'DeezerError';
24
+ this.code = code;
25
+ this.keys = keys;
26
+ this.payload = p;
27
+ this.retryable = (_b = opts.retryable) !== null && _b !== void 0 ? _b : isRetryable(code, keys);
28
+ }
29
+ /** Whether a `code` / key set is worth retrying. */
30
+ static retryable(code, keys) {
31
+ return isRetryable(code, keys);
32
+ }
33
+ }
34
+ exports.DeezerError = DeezerError;
35
+ const describe = (payload, keys) => {
36
+ if (!keys.length)
37
+ return 'Deezer request failed';
38
+ return keys.map((k) => `${k}: ${String(payload[k])}`).join(', ');
39
+ };
40
+ const isRetryable = (code, keys) => {
41
+ if (code === 4)
42
+ return true; // Deezer's transient "quota exceeded"
43
+ return keys.some((k) => RETRYABLE_KEYS.has(k));
44
+ };
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.resolveDownloadUrls = exports.getTrackDownloadUrl = exports.formatName = exports.toFormat = exports.DEEZER_FORMATS = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const decrypt_1 = require("../lib/decrypt");
9
+ const errors_1 = require("../lib/errors");
9
10
  const http_1 = require("../lib/http");
10
11
  const request_1 = __importDefault(require("../lib/request"));
11
12
  class WrongLicense extends Error {
@@ -137,7 +138,7 @@ const parseMediaEntry = (entry, token, country) => {
137
138
  throw new GeoBlocked(country);
138
139
  if (code === 2000 || code === 2001)
139
140
  throw new ExpiredTrackToken(token);
140
- throw new Error(Object.entries(entry.errors[0]).join(', '));
141
+ throw new errors_1.DeezerError(entry.errors[0]);
141
142
  }
142
143
  const media = (_a = entry === null || entry === void 0 ? void 0 : entry.media) === null || _a === void 0 ? void 0 : _a[0];
143
144
  if (!((_b = media === null || media === void 0 ? void 0 : media.sources) === null || _b === void 0 ? void 0 : _b.length))
@@ -3,6 +3,26 @@ type DeezerRequestConfig = {
3
3
  headers?: Record<string, string>;
4
4
  params?: HttpQuery;
5
5
  };
6
+ /**
7
+ * Bounded-retry policy for the gateway. Every retry class has its own attempt
8
+ * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
9
+ * can no longer spin forever (the old code had no cap on `code === 4`,
10
+ * `NEED_API_AUTH_REQUIRED` or `GATEWAY_ERROR`).
11
+ */
12
+ export declare const RETRY_POLICY: {
13
+ /** total attempts for the transient `code === 4` class */
14
+ code4Attempts: number;
15
+ /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
16
+ authReinits: number;
17
+ /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
18
+ tokenRefreshes: number;
19
+ /** base backoff (ms) — grows exponentially, full-jittered */
20
+ baseMs: number;
21
+ /** cap on a single backoff wait */
22
+ maxDelayMs: number;
23
+ /** overall wall-clock budget from the first attempt */
24
+ deadlineMs: number;
25
+ };
6
26
  export declare const initDeezerApi: (arl: string) => Promise<string>;
7
27
  declare const _default: {
8
28
  defaults: {
@@ -3,10 +3,36 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.initDeezerApi = void 0;
6
+ exports.initDeezerApi = exports.RETRY_POLICY = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const http_1 = require("./http");
9
+ const errors_1 = require("./errors");
9
10
  let user_arl = 'c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416';
11
+ /**
12
+ * Bounded-retry policy for the gateway. Every retry class has its own attempt
13
+ * cap **and** there is a wall-clock deadline, so a persistently failing endpoint
14
+ * can no longer spin forever (the old code had no cap on `code === 4`,
15
+ * `NEED_API_AUTH_REQUIRED` or `GATEWAY_ERROR`).
16
+ */
17
+ exports.RETRY_POLICY = {
18
+ /** total attempts for the transient `code === 4` class */
19
+ code4Attempts: 6,
20
+ /** re-inits allowed for `NEED_API_AUTH_REQUIRED` */
21
+ authReinits: 3,
22
+ /** token refreshes allowed for `GATEWAY_ERROR` / `VALID_TOKEN_REQUIRED` */
23
+ tokenRefreshes: 15,
24
+ /** base backoff (ms) — grows exponentially, full-jittered */
25
+ baseMs: 800,
26
+ /** cap on a single backoff wait */
27
+ maxDelayMs: 8000,
28
+ /** overall wall-clock budget from the first attempt */
29
+ deadlineMs: 30000,
30
+ };
31
+ /** Exponential backoff (ms) with full jitter on the top half of the window. */
32
+ const backoffDelay = (attempt) => {
33
+ const windowMs = Math.min(exports.RETRY_POLICY.baseMs * 2 ** attempt, exports.RETRY_POLICY.maxDelayMs);
34
+ return windowMs / 2 + Math.random() * (windowMs / 2);
35
+ };
10
36
  const instance = new http_1.HttpClient({
11
37
  baseURL: 'https://api.deezer.com/1.0',
12
38
  timeout: 15000,
@@ -54,30 +80,41 @@ const initDeezerApi = async (arl) => {
54
80
  return data.results.SESSION;
55
81
  };
56
82
  exports.initDeezerApi = initDeezerApi;
57
- let token_retry = 0;
58
83
  const requestWithRetry = async (method, url, body, config = {}) => {
59
84
  var _a;
60
- const response = method === 'POST' ? await instance.post(url, body, config) : await instance.get(url, config);
61
- const error = (_a = response.data) === null || _a === void 0 ? void 0 : _a.error;
62
- if (!error || Object.keys(error).length === 0) {
63
- token_retry = 0;
64
- return response;
65
- }
66
- if (error.NEED_API_AUTH_REQUIRED) {
67
- await (0, exports.initDeezerApi)(user_arl);
68
- return await requestWithRetry(method, url, body, config);
69
- }
70
- if (error.code === 4) {
71
- await delay_1.default.range(1000, 1500);
72
- return await requestWithRetry(method, url, body, config);
73
- }
74
- if (error.GATEWAY_ERROR || (error.VALID_TOKEN_REQUIRED && token_retry < 15)) {
75
- token_retry += 1;
76
- await getApiToken();
77
- return await requestWithRetry(method, url, body, config);
85
+ const startedAt = Date.now();
86
+ let authReinits = 0;
87
+ let tokenRefreshes = 0;
88
+ let code4Attempts = 0;
89
+ // eslint-disable-next-line no-constant-condition
90
+ while (true) {
91
+ const response = method === 'POST' ? await instance.post(url, body, config) : await instance.get(url, config);
92
+ const error = (_a = response.data) === null || _a === void 0 ? void 0 : _a.error;
93
+ if (!error || Object.keys(error).length === 0) {
94
+ return response;
95
+ }
96
+ const overDeadline = Date.now() - startedAt > exports.RETRY_POLICY.deadlineMs;
97
+ if (error.NEED_API_AUTH_REQUIRED && authReinits < exports.RETRY_POLICY.authReinits && !overDeadline) {
98
+ authReinits += 1;
99
+ await (0, exports.initDeezerApi)(user_arl);
100
+ continue;
101
+ }
102
+ if ((error.GATEWAY_ERROR || error.VALID_TOKEN_REQUIRED) &&
103
+ tokenRefreshes < exports.RETRY_POLICY.tokenRefreshes &&
104
+ !overDeadline) {
105
+ tokenRefreshes += 1;
106
+ await getApiToken();
107
+ await (0, delay_1.default)(backoffDelay(tokenRefreshes - 1));
108
+ continue;
109
+ }
110
+ if (error.code === 4 && code4Attempts < exports.RETRY_POLICY.code4Attempts && !overDeadline) {
111
+ await (0, delay_1.default)(backoffDelay(code4Attempts));
112
+ code4Attempts += 1;
113
+ continue;
114
+ }
115
+ // Unhandled error, or a retry class ran out of attempts / hit the deadline.
116
+ throw new errors_1.DeezerError(error);
78
117
  }
79
- token_retry = 0;
80
- return response;
81
118
  };
82
119
  exports.default = {
83
120
  defaults: instance.defaults,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",