channel-worker 2.5.150 → 2.5.151

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/lib/api-client.js CHANGED
@@ -1,7 +1,21 @@
1
+ // Final-result reports (done / failed / pending) ride out an API restart:
2
+ // one lost PUT left the command 'running' until the stale cleanup marked it
3
+ // "Timed out — có thể bài ĐÃ lên" and the scheduler stopped retrying the
4
+ // idea. ~2.5 minutes covers a pm2 restart with room to spare.
5
+ const REPORT_RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000, 30000, 60000];
6
+
7
+ // The API never answered: connection refused/reset, a gateway 502/503/504, or
8
+ // a non-JSON page while it restarts. An answer from the API itself — success:
9
+ // false, even a 500 from a handler — is final: repeating it changes nothing.
10
+ class ApiUnreachableError extends Error {}
11
+ const GATEWAY_STATUSES = new Set([502, 503, 504]);
12
+
1
13
  class ApiClient {
2
14
  constructor(baseUrl, workerToken) {
3
15
  this.baseUrl = baseUrl.replace(/\/$/, '');
4
16
  this.workerToken = workerToken || '';
17
+ this.reportRetryDelaysMs = REPORT_RETRY_DELAYS_MS;
18
+ this.sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
5
19
  }
6
20
 
7
21
  async request(method, path, body = null) {
@@ -15,8 +29,21 @@ class ApiClient {
15
29
  };
16
30
  if (body) options.body = JSON.stringify(body);
17
31
 
18
- const res = await fetch(url, options);
19
- const json = await res.json();
32
+ let res;
33
+ try {
34
+ res = await fetch(url, options);
35
+ } catch (err) {
36
+ throw new ApiUnreachableError(`API unreachable: ${err.message}`);
37
+ }
38
+ let json;
39
+ try {
40
+ json = await res.json();
41
+ } catch {
42
+ throw new ApiUnreachableError(`API error ${res.status} (no JSON body)`);
43
+ }
44
+ if (GATEWAY_STATUSES.has(res.status) && !json?.success) {
45
+ throw new ApiUnreachableError(json?.message || `API error ${res.status}`);
46
+ }
20
47
 
21
48
  if (!json.success) {
22
49
  throw new Error(json.message || `API error ${res.status}`);
@@ -24,6 +51,21 @@ class ApiClient {
24
51
  return json.data;
25
52
  }
26
53
 
54
+ // Same request, retried while the API is unreachable. Only for writes that
55
+ // are safe to repeat — every report here sets a final state.
56
+ async reportRequest(method, path, body) {
57
+ for (let attempt = 0; ; attempt++) {
58
+ try {
59
+ return await this.request(method, path, body);
60
+ } catch (err) {
61
+ const delay = this.reportRetryDelaysMs[attempt];
62
+ if (!(err instanceof ApiUnreachableError) || delay === undefined) throw err;
63
+ console.warn(`[api] ${method} ${path} failed (${err.message}) — retry ${attempt + 1}/${this.reportRetryDelaysMs.length} in ${Math.round(delay / 1000)}s`);
64
+ await this.sleep(delay);
65
+ }
66
+ }
67
+ }
68
+
27
69
  // Worker
28
70
  async register(workerData) {
29
71
  return this.request('POST', '/workers/register', workerData);
@@ -79,8 +121,12 @@ class ApiClient {
79
121
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
122
  }
81
123
 
124
+ // A 'running' touch is a liveness beat: NOT retried — a beat landing late
125
+ // would overwrite the final 'done' that followed it.
82
126
  async updateCommand(commandId, data) {
83
- return this.request('PUT', `/workers/commands/${commandId}`, data);
127
+ const path = `/workers/commands/${commandId}`;
128
+ if (data?.status === 'running') return this.request('PUT', path, data);
129
+ return this.reportRequest('PUT', path, data);
84
130
  }
85
131
 
86
132
  // Report a command result via the extension-style endpoint, which also
@@ -88,7 +134,7 @@ class ApiClient {
88
134
  // idea UI reflects pw-pipeline outcomes the same way it does for extension
89
135
  // uploads). Use this for upload_*_pw results.
90
136
  async postCommandResult(commandId, { status, result, error } = {}) {
91
- return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
137
+ return this.reportRequest('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
92
138
  }
93
139
 
94
140
  // Shopee scraper callbacks — worker ships parsed products, API upserts into
@@ -222,4 +268,4 @@ class ApiClient {
222
268
  }
223
269
  }
224
270
 
225
- module.exports = { ApiClient };
271
+ module.exports = { ApiClient, ApiUnreachableError, REPORT_RETRY_DELAYS_MS };
@@ -2,6 +2,7 @@ const { NstManager } = require('./nst-manager');
2
2
  const { refreshPinnedAgentAddress } = require('./nst-agent-locator');
3
3
  const { checkAndUpdateExtension } = require('./extension-updater');
4
4
  const { StatsSyncer } = require('./stats-syncer');
5
+ const { ApiUnreachableError } = require('./api-client');
5
6
 
6
7
  // Quãng RẢNH trước khi đóng profile của luồng đăng bài. Phải đủ dài để lượt
7
8
  // upload nền tảng kế tiếp (facebook → youtube → tiktok của cùng một idea) kịp
@@ -284,11 +285,23 @@ class CommandPoller {
284
285
  )), ms);
285
286
  });
286
287
  try {
287
- const patch = await Promise.race([job, deadline]);
288
- await this.api.updateCommand(command._id, patch);
289
- } catch (err) {
290
- console.error(`${failLog}: ${err.message}`);
291
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
288
+ let patch;
289
+ try {
290
+ patch = await Promise.race([job, deadline]);
291
+ } catch (err) {
292
+ console.error(`${failLog}: ${err.message}`);
293
+ patch = { status: 'failed', error: String(err.message || err).slice(0, 500) };
294
+ }
295
+ // Outside the work's try: a report that cannot reach the API must not
296
+ // turn a finished job into 'failed'.
297
+ try {
298
+ await this.api.updateCommand(command._id, patch);
299
+ } catch (err) {
300
+ console.error(`${failLog}: ${patch?.status} NOT reported: ${err.message}`);
301
+ if (!(err instanceof ApiUnreachableError) && patch?.status !== 'failed') {
302
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) }).catch(() => {});
303
+ }
304
+ }
292
305
  } finally {
293
306
  clearTimeout(timer);
294
307
  abandoned = true;
@@ -747,23 +760,43 @@ class CommandPoller {
747
760
  }, RUN_BEAT_MS);
748
761
 
749
762
  try {
750
- const result = await runPlaywrightScript({
751
- nst: this.nst,
752
- profileId,
753
- scriptName,
754
- payload,
755
- log,
756
- keepOpen: keepOpen.enabled,
757
- info: runInfo,
758
- });
759
- // postCommandResult (not updateCommand) so the existing extension-side
760
- // logic mirrors status into ContentJob + ContentIdea.publish_results +
761
- // broadcasts the WS update — pw and extension paths share idea-state UX.
762
- await this.api.postCommandResult(command._id, { status: 'done', result: result || {} });
763
- console.log(`[commands/pw] ${command.type} done`);
764
- } catch (err) {
765
- console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
766
- await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
763
+ let outcome;
764
+ try {
765
+ const result = await runPlaywrightScript({
766
+ nst: this.nst,
767
+ profileId,
768
+ scriptName,
769
+ payload,
770
+ log,
771
+ keepOpen: keepOpen.enabled,
772
+ info: runInfo,
773
+ });
774
+ outcome = { status: 'done', result: result || {} };
775
+ console.log(`[commands/pw] ${command.type} done`);
776
+ } catch (err) {
777
+ console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
778
+ outcome = { status: 'failed', error: String(err.message || err).slice(0, 500) };
779
+ }
780
+ // Stop the 'running' beat first: a beat landing after the result would
781
+ // put the command back to 'running'.
782
+ clearInterval(beat);
783
+ // Reported once, outside the script's try: a report that cannot reach
784
+ // the API used to fall into the catch above and report the posted video
785
+ // as 'failed'. postCommandResult (not updateCommand) so the existing
786
+ // extension-side logic mirrors status into ContentJob +
787
+ // ContentIdea.publish_results + broadcasts the WS update — pw and
788
+ // extension paths share idea-state UX.
789
+ try {
790
+ await this.api.postCommandResult(command._id, outcome);
791
+ } catch (err) {
792
+ console.error(`[commands/pw] ${command.type} ${outcome.status} NOT reported: ${err.message}`);
793
+ // The API answered and refused it: settle the command as failed
794
+ // rather than leave it 'running'. Unreachable after every retry:
795
+ // nothing more can be sent.
796
+ if (!(err instanceof ApiUnreachableError) && outcome.status !== 'failed') {
797
+ await this.api.postCommandResult(command._id, { status: 'failed', error: `API từ chối kết quả: ${String(err.message).slice(0, 300)}` }).catch(() => {});
798
+ }
799
+ }
767
800
  } finally {
768
801
  clearInterval(beat);
769
802
  // Always release the per-profile mutex — even on throw — or sibling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.150",
3
+ "version": "2.5.151",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {