Skip to content

Commit

Permalink
Throw custom errors from spawn (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukekarrys authored Jun 18, 2021
1 parent ef5cfcc commit 766de2f
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 34 deletions.
36 changes: 36 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@

const maxRetry = 3

class GitError extends Error {
shouldRetry () {
return false
}
}

class GitConnectionError extends GitError {
constructor (message) {
super('A git connection error occurred')
}

shouldRetry (number) {
return number < maxRetry
}
}

class GitPathspecError extends GitError {
constructor (message) {
super('The git reference could not be found')
}
}

class GitUnknownError extends GitError {
constructor (message) {
super('An unknown git error occurred')
}
}

module.exports = {
GitConnectionError,
GitPathspecError,
GitUnknownError
}
3 changes: 2 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ module.exports = {
spawn: require('./spawn.js'),
is: require('./is.js'),
find: require('./find.js'),
isClean: require('./is-clean.js')
isClean: require('./is-clean.js'),
errors: require('./errors.js')
}
33 changes: 33 additions & 0 deletions lib/make-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const {
GitConnectionError,
GitPathspecError,
GitUnknownError
} = require('./errors.js')

const connectionErrorRe = new RegExp([
'remote error: Internal Server Error',
'The remote end hung up unexpectedly',
'Connection timed out',
'Operation timed out',
'Failed to connect to .* Timed out',
'Connection reset by peer',
'SSL_ERROR_SYSCALL',
'The requested URL returned error: 503'
].join('|'))

const missingPathspecRe = /pathspec .* did not match any file\(s\) known to git/

function makeError (er) {
const message = er.stderr
let gitEr
if (connectionErrorRe.test(message)) {
gitEr = new GitConnectionError(message)
} else if (missingPathspecRe.test(message)) {
gitEr = new GitPathspecError(message)
} else {
gitEr = new GitUnknownError(message)
}
return Object.assign(gitEr, er)
}

module.exports = makeError
17 changes: 0 additions & 17 deletions lib/should-retry.js

This file was deleted.

9 changes: 5 additions & 4 deletions lib/spawn.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const spawn = require('@npmcli/promise-spawn')
const promiseRetry = require('promise-retry')
const shouldRetry = require('./should-retry.js')
const makeError = require('./make-error.js')
const whichGit = require('./which.js')
const makeOpts = require('./opts.js')
const procLog = require('./proc-log.js')
Expand Down Expand Up @@ -33,10 +33,11 @@ module.exports = (gitArgs, opts = {}) => {

return spawn(gitPath, args, makeOpts(opts))
.catch(er => {
if (!shouldRetry(er.stderr, number)) {
throw er
const gitError = makeError(er)
if (!gitError.shouldRetry(number)) {
throw gitError
}
retry(er)
retry(gitError)
})
}, retry)
}
7 changes: 6 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@ const t = require('tap')
t.match(git, {
clone: Function,
revs: Function,
spawn: Function
spawn: Function,
errors: {
GitConnectionError: /GitConnectionError/,
GitPathspecError: /GitPathspecError/,
GitUnknownError: /GitUnknownError/
}
})
30 changes: 30 additions & 0 deletions test/make-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const _makeError = require('../lib/make-error.js')
const errors = require('../lib/errors.js')
const t = require('tap')

const makeError = (message) =>
// Create the error with properties like it came from promise-spawn
_makeError(Object.assign(new Error(), { stderr: message }))

t.test('throw matching error for missing pathspec', (t) => {
const missingPathspec = makeError('error: pathspec \'foo\' did not match any file(s) known to git')
t.ok(missingPathspec instanceof errors.GitPathspecError, 'error is a pathspec error')

t.end()
})

t.test('only transient connection errors are retried', (t) => {
const sslError = makeError('SSL_ERROR_SYSCALL')
t.ok(sslError.shouldRetry(1), 'transient error, not beyond max')
t.ok(sslError instanceof errors.GitConnectionError, 'error is a connection error')

const unknownError = makeError('asdf')
t.notOk(unknownError.shouldRetry(1), 'unknown error, do not retry')
t.ok(unknownError instanceof errors.GitUnknownError, 'error is an unknown error')

const connectError = makeError('Failed to connect to fooblz Timed out')
t.notOk(connectError.shouldRetry(69), 'beyond max retries, do not retry')
t.ok(connectError instanceof errors.GitConnectionError, 'error is a connection error')

t.end()
})
6 changes: 0 additions & 6 deletions test/should-retry.js

This file was deleted.

62 changes: 57 additions & 5 deletions test/spawn.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const spawn = require('../lib/spawn.js')
const procLog = require('../lib/proc-log.js')
const errors = require('../lib/errors.js')

const t = require('tap')
t.rejects(spawn(['status'], { git: false }), {
Expand Down Expand Up @@ -44,9 +45,10 @@ t.test('argument test for allowReplace', async t => {
t.test('retries', t => {
const logs = []
process.on('log', (...log) => logs.push(log))
const gitMessage = 'Connection timed out\n'
const te = resolve(repo, 'transient-error.js')
fs.writeFileSync(te, `
console.error('Connection timed out')
console.error('${gitMessage.trim()}')
process.exit(1)
`)
const retryOptions = {
Expand All @@ -65,15 +67,15 @@ process.exit(1)
fetchRetryMintimeout: 1
}
}
const er = {
message: 'command failed',
const er = Object.assign(new errors.GitConnectionError(gitMessage), {
cmd: process.execPath,
args: [te],
code: 1,
signal: null,
stdout: '',
stderr: 'Connection timed out\n'
}
stderr: gitMessage,
message: 'A git connection error occurred'
})
Object.keys(retryOptions).forEach(n => t.test(n, t =>
t.rejects(spawn([te], {
cwd: repo,
Expand All @@ -98,3 +100,53 @@ process.exit(1)
})))
t.end()
})

t.test('missing pathspec', t => {
const gitMessage = 'error: pathspec \'foo\' did not match any file(s) known to git\n'
const te = resolve(repo, 'pathspec-error.js')
fs.writeFileSync(te, `
console.error("${gitMessage.trim()}")
process.exit(1)
`)
const er = Object.assign(new errors.GitPathspecError(gitMessage), {
cmd: process.execPath,
args: [te],
code: 1,
signal: null,
stdout: '',
stderr: gitMessage,
message: 'The git reference could not be found'
})
t.rejects(spawn([te], {
cwd: repo,
git: process.execPath,
allowReplace: true,
log: procLog
}), er)
t.end()
})

t.test('unknown git error', t => {
const gitMessage = 'error: something really bad happened to git\n'
const te = resolve(repo, 'unknown-error.js')
fs.writeFileSync(te, `
console.error("${gitMessage.trim()}")
process.exit(1)
`)
const er = Object.assign(new errors.GitUnknownError(gitMessage), {
cmd: process.execPath,
args: [te],
code: 1,
signal: null,
stdout: '',
stderr: gitMessage,
message: 'An unknown git error occurred'
})
t.rejects(spawn([te], {
cwd: repo,
git: process.execPath,
allowReplace: true,
log: procLog
}), er)
t.end()
})

0 comments on commit 766de2f

Please sign in to comment.