Skip to content

Commit f2aba82

Browse files
committed
fix(license): centralise license-server calls behind fetchWithTimeout (12s + 1 retry)
Matches the robustness fix already landed in magic-sessionmanager and magic-link. Before this change, 3 of the 4 outbound license calls used raw `fetch()` with no timeout — the fourth had a manually-rolled 5s AbortController that was too aggressive for cold-starting license servers and triggered spurious "[WARNING] License verification timeout - grace period active" on boot even when everything was fine. Changes in services/license-guard.js: - New fetchWithTimeout helper (same contract as in magic-sessionmanager and magic-link): 12s default timeout, 1 automatic retry with a 750ms backoff, each attempt with a fresh AbortController. Overridable via MAGIC_LICENSE_TIMEOUT_MS env var. - verifyLicense: the hand-rolled 5s AbortController is removed; the call now uses fetchWithTimeout. The grace-period log message is demoted from `warn` to `info` and its text is honest about what happened ("unreachable after retry, continuing on grace period"). - create / ping / key calls: all moved over to fetchWithTimeout so they can't silently hang the plugin boot on a slow upstream. No behavior change for the happy path. The grace-period window (24h) is unchanged.
1 parent 0ff357c commit f2aba82

1 file changed

Lines changed: 61 additions & 16 deletions

File tree

server/src/services/license-guard.js

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/**
22
* License Guard Service for MagicMail
3-
* Handles license creation, verification, and ping tracking
3+
* Handles license creation, verification, and ping tracking.
4+
*
5+
* All outbound HTTP calls go through `fetchWithTimeout` which adds a
6+
* hard timeout via AbortController plus one automatic retry, so a
7+
* cold-starting license server does not crash the call. Without this
8+
* guard users saw spurious "This operation was aborted" warnings on
9+
* boot whenever the upstream needed a few seconds to wake up.
410
*/
511

612
const crypto = require('crypto');
@@ -11,6 +17,46 @@ const { createLogger } = require('../utils/logger');
1117
// FIXED LICENSE SERVER URL
1218
const LICENSE_SERVER_URL = 'https://magicapi.fitlex.me';
1319

20+
// 12s default tolerates a cold-start on the license server (serverless
21+
// containers need 5–10s for the first TLS handshake). Configurable via
22+
// MAGIC_LICENSE_TIMEOUT_MS for unusually fast or slow networks.
23+
const envTimeout = Number(process.env.MAGIC_LICENSE_TIMEOUT_MS);
24+
const DEFAULT_FETCH_TIMEOUT_MS = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 12000;
25+
const FETCH_RETRIES = 1;
26+
const FETCH_RETRY_BACKOFF_MS = 750;
27+
28+
/**
29+
* Wraps `fetch` with a hard timeout via AbortController and one retry
30+
* so a cold-start on the license server does not crash the call. Each
31+
* attempt uses a fresh AbortController (a shared one would cancel the
32+
* retry before it could connect).
33+
*
34+
* @param {string} url
35+
* @param {object} [options]
36+
* @param {number} [timeoutMs]
37+
* @returns {Promise<Response>}
38+
*/
39+
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
40+
let lastError;
41+
for (let attempt = 0; attempt <= FETCH_RETRIES; attempt++) {
42+
const controller = new AbortController();
43+
const timer = setTimeout(() => controller.abort(), timeoutMs);
44+
try {
45+
return await fetch(url, { ...options, signal: controller.signal });
46+
} catch (err) {
47+
lastError = err;
48+
if (attempt < FETCH_RETRIES) {
49+
await new Promise((r) => setTimeout(r, FETCH_RETRY_BACKOFF_MS));
50+
continue;
51+
}
52+
throw err;
53+
} finally {
54+
clearTimeout(timer);
55+
}
56+
}
57+
throw lastError;
58+
}
59+
1460
module.exports = ({ strapi }) => {
1561
const log = createLogger(strapi);
1662

@@ -86,7 +132,7 @@ module.exports = ({ strapi }) => {
86132
const userAgent = this.getUserAgent();
87133

88134
const licenseServerUrl = this.getLicenseServerUrl();
89-
const response = await fetch(`${licenseServerUrl}/api/licenses/create`, {
135+
const response = await fetchWithTimeout(`${licenseServerUrl}/api/licenses/create`, {
90136
method: 'POST',
91137
headers: { 'Content-Type': 'application/json' },
92138
body: JSON.stringify({
@@ -119,32 +165,31 @@ module.exports = ({ strapi }) => {
119165

120166
async verifyLicense(licenseKey, allowGracePeriod = false) {
121167
try {
122-
const controller = new AbortController();
123-
const timeoutId = setTimeout(() => controller.abort(), 5000);
124-
125168
const licenseServerUrl = this.getLicenseServerUrl();
126-
const response = await fetch(`${licenseServerUrl}/api/licenses/verify`, {
169+
// Timeout + retry handled by fetchWithTimeout.
170+
const response = await fetchWithTimeout(`${licenseServerUrl}/api/licenses/verify`, {
127171
method: 'POST',
128172
headers: { 'Content-Type': 'application/json' },
129-
body: JSON.stringify({
173+
body: JSON.stringify({
130174
licenseKey,
131175
pluginName: 'magic-mail',
132176
productName: 'MagicMail - Email Business Suite',
133177
}),
134-
signal: controller.signal,
135178
});
136-
137-
clearTimeout(timeoutId);
179+
138180
const data = await response.json();
139181

140182
if (data.success && data.data) {
141183
return { valid: true, data: data.data, gracePeriod: false };
142-
} else {
143-
return { valid: false, data: null };
144184
}
185+
return { valid: false, data: null };
145186
} catch (error) {
146187
if (allowGracePeriod) {
147-
log.warn('[WARNING] License verification timeout - grace period active');
188+
// fetchWithTimeout already retried once — logging as info here
189+
// because grace-period is a graceful fallback, not a defect.
190+
log.info(
191+
`License server unreachable after retry, continuing on grace period (${error.message})`
192+
);
148193
return { valid: true, data: null, gracePeriod: true };
149194
}
150195
log.error('[ERROR] License verification error:', error.message);
@@ -156,8 +201,8 @@ module.exports = ({ strapi }) => {
156201
try {
157202
const licenseServerUrl = this.getLicenseServerUrl();
158203
const url = `${licenseServerUrl}/api/licenses/key/${licenseKey}`;
159-
160-
const response = await fetch(url, {
204+
205+
const response = await fetchWithTimeout(url, {
161206
method: 'GET',
162207
headers: { 'Content-Type': 'application/json' },
163208
});
@@ -183,7 +228,7 @@ module.exports = ({ strapi }) => {
183228
const userAgent = this.getUserAgent();
184229

185230
const licenseServerUrl = this.getLicenseServerUrl();
186-
const response = await fetch(`${licenseServerUrl}/api/licenses/ping`, {
231+
const response = await fetchWithTimeout(`${licenseServerUrl}/api/licenses/ping`, {
187232
method: 'POST',
188233
headers: { 'Content-Type': 'application/json' },
189234
body: JSON.stringify({

0 commit comments

Comments
 (0)