@opentermsarchive/engine 10.3.2 → 10.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/config/default.json +2 -1
- package/package.json +1 -1
- package/scripts/dataset/publish/github/index.js +1 -1
- package/scripts/dataset/publish/index.js +16 -13
- package/src/collection-api/logger.js +6 -4
- package/src/logger/index.js +6 -5
- package/src/logger/mail-transport-with-retry.js +46 -0
- package/src/logger/mail-transport-with-retry.test.js +192 -0
package/config/default.json
CHANGED
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ export default async function publish({ archivePath, releaseDate, stats }) {
|
|
|
30
30
|
logger.info('Uploading release asset…');
|
|
31
31
|
|
|
32
32
|
await octokit.rest.repos.uploadReleaseAsset({
|
|
33
|
-
data: fsApi.
|
|
33
|
+
data: fsApi.createReadStream(archivePath),
|
|
34
34
|
headers: {
|
|
35
35
|
'content-type': 'application/zip',
|
|
36
36
|
'content-length': fsApi.statSync(archivePath).size,
|
|
@@ -22,26 +22,29 @@ export default async function publishRelease({ archivePath, releaseDate, stats }
|
|
|
22
22
|
throw new Error('No publishing platform configured. Please configure at least one of: GitHub (OTA_ENGINE_GITHUB_TOKEN), GitLab (OTA_ENGINE_GITLAB_TOKEN), or data.gouv.fr (OTA_ENGINE_DATAGOUV_API_KEY + datasetId or organizationIdOrSlug in config).');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const succeeded = [];
|
|
26
|
+
const failed = [];
|
|
27
|
+
|
|
28
|
+
// Execute publications sequentially to avoid memory issues with large file uploads
|
|
29
|
+
for (const platform of platforms) {
|
|
30
|
+
try {
|
|
31
|
+
const url = await platform.publish();
|
|
32
|
+
|
|
33
|
+
succeeded.push({ platform: platform.name, url });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
failed.push({ platform: platform.name, error });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
33
38
|
|
|
34
39
|
if (failed.length) {
|
|
35
40
|
let errorMessage = !succeeded.length ? 'All platforms failed to publish:' : 'Some platforms failed to publish:';
|
|
36
41
|
|
|
37
|
-
failed.forEach(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
errorMessage += `\n - ${platforms[index].name}: ${rejectedResult.reason.message}`;
|
|
42
|
+
failed.forEach(({ platform, error }) => {
|
|
43
|
+
errorMessage += `\n - ${platform}: ${error.message}`;
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
logger.error(errorMessage);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
return succeeded
|
|
49
|
+
return succeeded;
|
|
47
50
|
}
|
|
@@ -3,7 +3,8 @@ import os from 'os';
|
|
|
3
3
|
import config from 'config';
|
|
4
4
|
import dotenv from 'dotenv';
|
|
5
5
|
import winston from 'winston';
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
import MailTransportWithRetry from '../logger/mail-transport-with-retry.js';
|
|
7
8
|
|
|
8
9
|
dotenv.config({ quiet: true });
|
|
9
10
|
|
|
@@ -12,14 +13,15 @@ const { combine, timestamp, printf, colorize } = winston.format;
|
|
|
12
13
|
const transports = [new winston.transports.Console()];
|
|
13
14
|
|
|
14
15
|
if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
15
|
-
transports.push(new
|
|
16
|
+
transports.push(new MailTransportWithRetry({
|
|
16
17
|
to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
|
|
17
18
|
from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
|
|
18
19
|
host: config.get('@opentermsarchive/engine.logger.smtp.host'),
|
|
20
|
+
port: config.get('@opentermsarchive/engine.logger.smtp.port'),
|
|
19
21
|
username: config.get('@opentermsarchive/engine.logger.smtp.username'),
|
|
20
22
|
password: process.env.OTA_ENGINE_SMTP_PASSWORD,
|
|
21
|
-
|
|
22
|
-
timeout:
|
|
23
|
+
tls: true,
|
|
24
|
+
timeout: 60 * 1000,
|
|
23
25
|
formatter: args => args[Object.getOwnPropertySymbols(args)[1]], // Returns the full error message, the same visible in the console. It is referenced in the argument object with a Symbol of which we do not have the reference but we know it is the second one.
|
|
24
26
|
exitOnError: true,
|
|
25
27
|
level: 'error',
|
package/src/logger/index.js
CHANGED
|
@@ -2,10 +2,10 @@ import os from 'os';
|
|
|
2
2
|
|
|
3
3
|
import config from 'config';
|
|
4
4
|
import winston from 'winston';
|
|
5
|
-
import 'winston-mail';
|
|
6
5
|
|
|
7
6
|
import { getCollection } from '../archivist/collection/index.js';
|
|
8
7
|
|
|
8
|
+
import MailTransportWithRetry from './mail-transport-with-retry.js';
|
|
9
9
|
import { formatDuration } from './utils.js';
|
|
10
10
|
|
|
11
11
|
const { combine, timestamp, printf, colorize } = winston.format;
|
|
@@ -57,10 +57,11 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
|
57
57
|
to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
|
|
58
58
|
from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
|
|
59
59
|
host: config.get('@opentermsarchive/engine.logger.smtp.host'),
|
|
60
|
+
port: config.get('@opentermsarchive/engine.logger.smtp.port'),
|
|
60
61
|
username: config.get('@opentermsarchive/engine.logger.smtp.username'),
|
|
61
62
|
password: process.env.OTA_ENGINE_SMTP_PASSWORD,
|
|
62
|
-
|
|
63
|
-
timeout:
|
|
63
|
+
tls: true,
|
|
64
|
+
timeout: 60 * 1000,
|
|
64
65
|
html: false,
|
|
65
66
|
formatter({ message, level }) {
|
|
66
67
|
const isError = level.includes('error');
|
|
@@ -141,14 +142,14 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
|
141
142
|
},
|
|
142
143
|
};
|
|
143
144
|
|
|
144
|
-
transports.push(new
|
|
145
|
+
transports.push(new MailTransportWithRetry({
|
|
145
146
|
...mailerOptions,
|
|
146
147
|
level: 'error',
|
|
147
148
|
subject: `Server error on ${collection.id} collection`,
|
|
148
149
|
}));
|
|
149
150
|
|
|
150
151
|
if (config.get('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings')) {
|
|
151
|
-
transports.push(new
|
|
152
|
+
transports.push(new MailTransportWithRetry({
|
|
152
153
|
...mailerOptions,
|
|
153
154
|
level: 'warn',
|
|
154
155
|
subject: `Inaccessible content on ${collection.id} collection`,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import async from 'async';
|
|
4
|
+
import winston from 'winston';
|
|
5
|
+
|
|
6
|
+
import 'winston-mail';
|
|
7
|
+
|
|
8
|
+
export const RETRY_DELAYS = [ 5000, 20000, 60000 ];
|
|
9
|
+
|
|
10
|
+
const RETRY_OPTIONS = {
|
|
11
|
+
times: RETRY_DELAYS.length + 1,
|
|
12
|
+
interval: retryCount => RETRY_DELAYS[retryCount - 1] || RETRY_DELAYS.at(-1),
|
|
13
|
+
errorFilter: error => {
|
|
14
|
+
console.warn(`SMTP mail sending failed: ${error.message}; retrying…`);
|
|
15
|
+
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class MailTransportWithRetry extends winston.Transport {
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super(options);
|
|
23
|
+
this.mailTransport = new winston.transports.Mail(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async log(info, callback) {
|
|
27
|
+
try {
|
|
28
|
+
await async.retry(RETRY_OPTIONS, async () => {
|
|
29
|
+
const result = Promise.race([
|
|
30
|
+
once(this.mailTransport, 'logged'),
|
|
31
|
+
once(this.mailTransport, 'error').then(([err]) => { throw err; }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
this.mailTransport.log(info, () => {});
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.warn(`SMTP mail sending failed after ${RETRY_OPTIONS.times} attempts: ${error.message}`);
|
|
40
|
+
this.emit('error', error);
|
|
41
|
+
}
|
|
42
|
+
callback();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default MailTransportWithRetry;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import sinon from 'sinon';
|
|
5
|
+
import winston from 'winston';
|
|
6
|
+
|
|
7
|
+
import MailTransportWithRetry, { RETRY_DELAYS } from './mail-transport-with-retry.js';
|
|
8
|
+
|
|
9
|
+
class MockMailTransport extends EventEmitter {
|
|
10
|
+
log() {} // eslint-disable-line
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('MailTransportWithRetry', () => {
|
|
14
|
+
let clock;
|
|
15
|
+
let mockMailTransport;
|
|
16
|
+
let transport;
|
|
17
|
+
let consoleWarnStub;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
clock = sinon.useFakeTimers();
|
|
21
|
+
mockMailTransport = new MockMailTransport();
|
|
22
|
+
sinon.stub(winston.transports, 'Mail').returns(mockMailTransport);
|
|
23
|
+
consoleWarnStub = sinon.stub(console, 'warn');
|
|
24
|
+
transport = new MailTransportWithRetry({ to: 'test@example.com' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
sinon.restore();
|
|
29
|
+
clock.restore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('#log', () => {
|
|
33
|
+
context('when email is sent successfully on first attempt', () => {
|
|
34
|
+
it('calls callback without error', async () => {
|
|
35
|
+
const callback = sinon.spy();
|
|
36
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
37
|
+
|
|
38
|
+
mockMailTransport.emit('logged');
|
|
39
|
+
await logPromise;
|
|
40
|
+
|
|
41
|
+
expect(callback).to.have.been.calledOnce;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not emit error event', async () => {
|
|
45
|
+
const errorHandler = sinon.spy();
|
|
46
|
+
|
|
47
|
+
transport.on('error', errorHandler);
|
|
48
|
+
|
|
49
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
50
|
+
|
|
51
|
+
mockMailTransport.emit('logged');
|
|
52
|
+
await logPromise;
|
|
53
|
+
|
|
54
|
+
expect(errorHandler).not.to.have.been.called;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not log any warning', async () => {
|
|
58
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
59
|
+
|
|
60
|
+
mockMailTransport.emit('logged');
|
|
61
|
+
await logPromise;
|
|
62
|
+
|
|
63
|
+
expect(consoleWarnStub).not.to.have.been.called;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
context('when email fails then succeeds on retry', () => {
|
|
68
|
+
it('retries and eventually succeeds', async () => {
|
|
69
|
+
const callback = sinon.spy();
|
|
70
|
+
const logSpy = sinon.spy(mockMailTransport, 'log');
|
|
71
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
72
|
+
|
|
73
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
74
|
+
|
|
75
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
76
|
+
|
|
77
|
+
mockMailTransport.emit('logged');
|
|
78
|
+
await logPromise;
|
|
79
|
+
|
|
80
|
+
expect(logSpy).to.have.been.calledTwice;
|
|
81
|
+
expect(callback).to.have.been.calledOnce;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('logs warning for failed attempt', async () => {
|
|
85
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
86
|
+
|
|
87
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
88
|
+
|
|
89
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
90
|
+
|
|
91
|
+
mockMailTransport.emit('logged');
|
|
92
|
+
await logPromise;
|
|
93
|
+
|
|
94
|
+
expect(consoleWarnStub).to.have.been.calledOnce;
|
|
95
|
+
expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP mail sending failed');
|
|
96
|
+
expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP timeout');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
context('when email fails after all retry attempts', () => {
|
|
101
|
+
it('emits error event after all retries are exhausted', async () => {
|
|
102
|
+
const errorHandler = sinon.spy();
|
|
103
|
+
|
|
104
|
+
transport.on('error', errorHandler);
|
|
105
|
+
|
|
106
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
107
|
+
|
|
108
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
109
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
110
|
+
|
|
111
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
112
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
113
|
+
|
|
114
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
115
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
116
|
+
|
|
117
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
118
|
+
await logPromise;
|
|
119
|
+
|
|
120
|
+
expect(errorHandler).to.have.been.calledOnce;
|
|
121
|
+
expect(errorHandler.firstCall.args[0].message).to.equal('SMTP timeout');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('calls callback even after failure', async () => {
|
|
125
|
+
const callback = sinon.spy();
|
|
126
|
+
|
|
127
|
+
transport.on('error', () => {}); // Prevent unhandled error
|
|
128
|
+
|
|
129
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
130
|
+
|
|
131
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
132
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
133
|
+
|
|
134
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
135
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
136
|
+
|
|
137
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
138
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
139
|
+
|
|
140
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
141
|
+
await logPromise;
|
|
142
|
+
|
|
143
|
+
expect(callback).to.have.been.calledOnce;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('logs final failure warning', async () => {
|
|
147
|
+
transport.on('error', () => {}); // Prevent unhandled error
|
|
148
|
+
|
|
149
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
150
|
+
|
|
151
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
152
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
153
|
+
|
|
154
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
155
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
156
|
+
|
|
157
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
158
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
159
|
+
|
|
160
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
161
|
+
await logPromise;
|
|
162
|
+
|
|
163
|
+
expect(consoleWarnStub.lastCall.args[0]).to.include(`failed after ${RETRY_DELAYS.length + 1} attempts`);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
context('when email succeeds after multiple failures', () => {
|
|
168
|
+
it('succeeds on third attempt', async () => {
|
|
169
|
+
const callback = sinon.spy();
|
|
170
|
+
const errorHandler = sinon.spy();
|
|
171
|
+
const logSpy = sinon.spy(mockMailTransport, 'log');
|
|
172
|
+
|
|
173
|
+
transport.on('error', errorHandler);
|
|
174
|
+
|
|
175
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
176
|
+
|
|
177
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
178
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
179
|
+
|
|
180
|
+
mockMailTransport.emit('error', new Error('Connection refused'));
|
|
181
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
182
|
+
|
|
183
|
+
mockMailTransport.emit('logged');
|
|
184
|
+
await logPromise;
|
|
185
|
+
|
|
186
|
+
expect(logSpy).to.have.been.calledThrice;
|
|
187
|
+
expect(callback).to.have.been.calledOnce;
|
|
188
|
+
expect(errorHandler).not.to.have.been.called;
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|