Skip to content

Commit 766de2f

Browse files
authored
Throw custom errors from spawn (#32)
Ref: npm/cli#3149
1 parent ef5cfcc commit 766de2f

File tree

9 files changed

+169
-34
lines changed

9 files changed

+169
-34
lines changed

Diff for: lib/errors.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
const maxRetry = 3
3+
4+
class GitError extends Error {
5+
shouldRetry () {
6+
return false
7+
}
8+
}
9+
10+
class GitConnectionError extends GitError {
11+
constructor (message) {
12+
super('A git connection error occurred')
13+
}
14+
15+
shouldRetry (number) {
16+
return number < maxRetry
17+
}
18+
}
19+
20+
class GitPathspecError extends GitError {
21+
constructor (message) {
22+
super('The git reference could not be found')
23+
}
24+
}
25+
26+
class GitUnknownError extends GitError {
27+
constructor (message) {
28+
super('An unknown git error occurred')
29+
}
30+
}
31+
32+
module.exports = {
33+
GitConnectionError,
34+
GitPathspecError,
35+
GitUnknownError
36+
}

Diff for: lib/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ module.exports = {
44
spawn: require('./spawn.js'),
55
is: require('./is.js'),
66
find: require('./find.js'),
7-
isClean: require('./is-clean.js')
7+
isClean: require('./is-clean.js'),
8+
errors: require('./errors.js')
89
}

Diff for: lib/make-error.js

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const {
2+
GitConnectionError,
3+
GitPathspecError,
4+
GitUnknownError
5+
} = require('./errors.js')
6+
7+
const connectionErrorRe = new RegExp([
8+
'remote error: Internal Server Error',
9+
'The remote end hung up unexpectedly',
10+
'Connection timed out',
11+
'Operation timed out',
12+
'Failed to connect to .* Timed out',
13+
'Connection reset by peer',
14+
'SSL_ERROR_SYSCALL',
15+
'The requested URL returned error: 503'
16+
].join('|'))
17+
18+
const missingPathspecRe = /pathspec .* did not match any file\(s\) known to git/
19+
20+
function makeError (er) {
21+
const message = er.stderr
22+
let gitEr
23+
if (connectionErrorRe.test(message)) {
24+
gitEr = new GitConnectionError(message)
25+
} else if (missingPathspecRe.test(message)) {
26+
gitEr = new GitPathspecError(message)
27+
} else {
28+
gitEr = new GitUnknownError(message)
29+
}
30+
return Object.assign(gitEr, er)
31+
}
32+
33+
module.exports = makeError

Diff for: lib/should-retry.js

-17
This file was deleted.

Diff for: lib/spawn.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const spawn = require('@npmcli/promise-spawn')
22
const promiseRetry = require('promise-retry')
3-
const shouldRetry = require('./should-retry.js')
3+
const makeError = require('./make-error.js')
44
const whichGit = require('./which.js')
55
const makeOpts = require('./opts.js')
66
const procLog = require('./proc-log.js')
@@ -33,10 +33,11 @@ module.exports = (gitArgs, opts = {}) => {
3333

3434
return spawn(gitPath, args, makeOpts(opts))
3535
.catch(er => {
36-
if (!shouldRetry(er.stderr, number)) {
37-
throw er
36+
const gitError = makeError(er)
37+
if (!gitError.shouldRetry(number)) {
38+
throw gitError
3839
}
39-
retry(er)
40+
retry(gitError)
4041
})
4142
}, retry)
4243
}

Diff for: test/index.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@ const t = require('tap')
33
t.match(git, {
44
clone: Function,
55
revs: Function,
6-
spawn: Function
6+
spawn: Function,
7+
errors: {
8+
GitConnectionError: /GitConnectionError/,
9+
GitPathspecError: /GitPathspecError/,
10+
GitUnknownError: /GitUnknownError/
11+
}
712
})

Diff for: test/make-error.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const _makeError = require('../lib/make-error.js')
2+
const errors = require('../lib/errors.js')
3+
const t = require('tap')
4+
5+
const makeError = (message) =>
6+
// Create the error with properties like it came from promise-spawn
7+
_makeError(Object.assign(new Error(), { stderr: message }))
8+
9+
t.test('throw matching error for missing pathspec', (t) => {
10+
const missingPathspec = makeError('error: pathspec \'foo\' did not match any file(s) known to git')
11+
t.ok(missingPathspec instanceof errors.GitPathspecError, 'error is a pathspec error')
12+
13+
t.end()
14+
})
15+
16+
t.test('only transient connection errors are retried', (t) => {
17+
const sslError = makeError('SSL_ERROR_SYSCALL')
18+
t.ok(sslError.shouldRetry(1), 'transient error, not beyond max')
19+
t.ok(sslError instanceof errors.GitConnectionError, 'error is a connection error')
20+
21+
const unknownError = makeError('asdf')
22+
t.notOk(unknownError.shouldRetry(1), 'unknown error, do not retry')
23+
t.ok(unknownError instanceof errors.GitUnknownError, 'error is an unknown error')
24+
25+
const connectError = makeError('Failed to connect to fooblz Timed out')
26+
t.notOk(connectError.shouldRetry(69), 'beyond max retries, do not retry')
27+
t.ok(connectError instanceof errors.GitConnectionError, 'error is a connection error')
28+
29+
t.end()
30+
})

Diff for: test/should-retry.js

-6
This file was deleted.

Diff for: test/spawn.js

+57-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const spawn = require('../lib/spawn.js')
22
const procLog = require('../lib/proc-log.js')
3+
const errors = require('../lib/errors.js')
34

45
const t = require('tap')
56
t.rejects(spawn(['status'], { git: false }), {
@@ -44,9 +45,10 @@ t.test('argument test for allowReplace', async t => {
4445
t.test('retries', t => {
4546
const logs = []
4647
process.on('log', (...log) => logs.push(log))
48+
const gitMessage = 'Connection timed out\n'
4749
const te = resolve(repo, 'transient-error.js')
4850
fs.writeFileSync(te, `
49-
console.error('Connection timed out')
51+
console.error('${gitMessage.trim()}')
5052
process.exit(1)
5153
`)
5254
const retryOptions = {
@@ -65,15 +67,15 @@ process.exit(1)
6567
fetchRetryMintimeout: 1
6668
}
6769
}
68-
const er = {
69-
message: 'command failed',
70+
const er = Object.assign(new errors.GitConnectionError(gitMessage), {
7071
cmd: process.execPath,
7172
args: [te],
7273
code: 1,
7374
signal: null,
7475
stdout: '',
75-
stderr: 'Connection timed out\n'
76-
}
76+
stderr: gitMessage,
77+
message: 'A git connection error occurred'
78+
})
7779
Object.keys(retryOptions).forEach(n => t.test(n, t =>
7880
t.rejects(spawn([te], {
7981
cwd: repo,
@@ -98,3 +100,53 @@ process.exit(1)
98100
})))
99101
t.end()
100102
})
103+
104+
t.test('missing pathspec', t => {
105+
const gitMessage = 'error: pathspec \'foo\' did not match any file(s) known to git\n'
106+
const te = resolve(repo, 'pathspec-error.js')
107+
fs.writeFileSync(te, `
108+
console.error("${gitMessage.trim()}")
109+
process.exit(1)
110+
`)
111+
const er = Object.assign(new errors.GitPathspecError(gitMessage), {
112+
cmd: process.execPath,
113+
args: [te],
114+
code: 1,
115+
signal: null,
116+
stdout: '',
117+
stderr: gitMessage,
118+
message: 'The git reference could not be found'
119+
})
120+
t.rejects(spawn([te], {
121+
cwd: repo,
122+
git: process.execPath,
123+
allowReplace: true,
124+
log: procLog
125+
}), er)
126+
t.end()
127+
})
128+
129+
t.test('unknown git error', t => {
130+
const gitMessage = 'error: something really bad happened to git\n'
131+
const te = resolve(repo, 'unknown-error.js')
132+
fs.writeFileSync(te, `
133+
console.error("${gitMessage.trim()}")
134+
process.exit(1)
135+
`)
136+
const er = Object.assign(new errors.GitUnknownError(gitMessage), {
137+
cmd: process.execPath,
138+
args: [te],
139+
code: 1,
140+
signal: null,
141+
stdout: '',
142+
stderr: gitMessage,
143+
message: 'An unknown git error occurred'
144+
})
145+
t.rejects(spawn([te], {
146+
cwd: repo,
147+
git: process.execPath,
148+
allowReplace: true,
149+
log: procLog
150+
}), er)
151+
t.end()
152+
})

0 commit comments

Comments
 (0)