Commit b4d1fb8

Anton Medvedev <anton@medv.io>
2022-06-08 00:10:58
New retry() api
1 parent c3f2d39
src/core.ts
@@ -20,7 +20,15 @@ import { Readable, Writable } from 'node:stream'
 import { inspect } from 'node:util'
 import { chalk, which } from './goods.js'
 import { log } from './log.js'
-import { exitCodeInfo, noop, psTree, quote, substitute } from './util.js'
+import {
+  Duration,
+  exitCodeInfo,
+  noop,
+  parseDuration,
+  psTree,
+  quote,
+  substitute,
+} from './util.js'
 
 export type Shell = (
   pieces: TemplateStringsArray,
@@ -113,7 +121,6 @@ export const $ = new Proxy<Shell & Options>(
 
 type Resolve = (out: ProcessOutput) => void
 type IO = StdioPipe | StdioNull
-export type Duration = number | `${number}s` | `${number}ms`
 
 export class ProcessPromise extends Promise<ProcessOutput> {
   child?: ChildProcess
@@ -300,15 +307,7 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   }
 
   timeout(d: Duration, signal = 'SIGTERM') {
-    if (typeof d == 'number') {
-      this._timeout = d
-    } else if (/\d+s/.test(d)) {
-      this._timeout = +d.slice(0, -1) * 1000
-    } else if (/\d+ms/.test(d)) {
-      this._timeout = +d.slice(0, -2)
-    } else {
-      throw new Error(`Unknown timeout duration: "${d}".`)
-    }
+    this._timeout = parseDuration(d)
     this._timeoutSignal = signal
     return this
   }
src/experimental.ts
@@ -12,22 +12,48 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import assert from 'node:assert'
 import { $ } from './core.js'
 import { sleep } from './goods.js'
+import { Duration, parseDuration } from './util.js'
 
-// Retries a command a few times. Will return after the first
-// successful attempt, or will throw after specifies attempts count.
-export function retry(count = 5, delay = 0) {
-  return async function (cmd: TemplateStringsArray, ...args: any[]) {
-    while (count-- > 0)
-      try {
-        return await $(cmd, ...args)
-      } catch (p) {
-        if (count === 0) throw p
-        if (delay) await sleep(delay)
+export async function retry<T>(count: number, callback: () => T): Promise<T>
+export async function retry<T>(
+  count: number,
+  duration: Duration,
+  callback?: () => T
+): Promise<T>
+export async function retry<T>(
+  count: number,
+  a: Duration | (() => T),
+  b?: () => T
+): Promise<T> {
+  const total = count
+  let cb: () => T
+  let delay = 0
+  if (typeof a == 'function') {
+    cb = a
+  } else {
+    delay = parseDuration(a)
+    assert(b)
+    cb = b
+  }
+  while (count-- > 0) {
+    try {
+      return await cb()
+    } catch (err) {
+      if ($.verbose) {
+        console.error(
+          chalk.bgRed.white(' FAIL '),
+          `Attempt: ${total - count}/${total}` +
+            (delay > 0 ? `; next in ${delay}ms` : '')
+        )
       }
-    return
+      if (count == 0) throw err
+      if (delay) await sleep(delay)
+    }
   }
+  throw 'unreachable'
 }
 
 /**
src/index.ts
@@ -18,7 +18,6 @@ export {
   $,
   Shell,
   Options,
-  Duration,
   ProcessPromise,
   ProcessOutput,
   within,
@@ -43,6 +42,8 @@ export {
 
 export { log, formatCmd, LogEntry } from './log.js'
 
+export { Duration } from './util.js'
+
 /**
  *  @deprecated Use $.nothrow() instead.
  */
src/repl.ts
@@ -20,7 +20,7 @@ import chalk from 'chalk'
 import { ProcessOutput } from './core.js'
 
 export function startRepl() {
-  (global as any).ZX_VERBOSE = false
+  ;(global as any).ZX_VERBOSE = false
   const r = repl.start({
     prompt: chalk.greenBright.bold('❯ '),
     useGlobal: true,
src/util.ts
@@ -120,3 +120,17 @@ export function stdin() {
     })()
   }
 }
+
+export type Duration = number | `${number}s` | `${number}ms`
+
+export function parseDuration(d: Duration) {
+  if (typeof d == 'number') {
+    return d
+  } else if (/\d+s/.test(d)) {
+    return +d.slice(0, -1) * 1000
+  } else if (/\d+ms/.test(d)) {
+    return +d.slice(0, -2)
+  } else {
+    throw new Error(`Unknown duration: "${d}".`)
+  }
+}
test/experimental.test.js
@@ -14,31 +14,38 @@
 
 import { suite } from 'uvu'
 import * as assert from 'uvu/assert'
-import { retry } from '../build/experimental.js'
 import '../build/globals.js'
 
 const test = suite('experimental')
 
 $.verbose = false
 
+function zx(script) {
+  return $`node build/cli.js --experimental --eval ${script}`
+}
+
 test('retry works', async () => {
-  let exitCode = 0
-  let now = Date.now()
-  try {
-    await retry(5, 50)`exit 123`
-  } catch (p) {
-    exitCode = p.exitCode
-  }
-  assert.is(exitCode, 123)
+  const now = Date.now()
+  let p = await zx(`
+    try {
+      await retry(5, 50, () => $\`exit 123\`)
+    } catch (e) {
+      echo('exitCode:', e.exitCode)
+    }
+    await retry(5, () => $\`exit 0\`)
+    echo('success')
+`)
+  assert.match(p.toString(), 'exitCode: 123')
+  assert.match(p.toString(), 'success')
   assert.ok(Date.now() >= now + 50 * (5 - 1))
 })
 
 test('spinner works', async () => {
-  let out = await $`node build/cli.js --experimental <<'EOF'
-  let stop = startSpinner('waiting')
-  await sleep(1000)
-  stop()
-EOF`
+  let out = await zx(`
+    let stop = startSpinner('waiting')
+    await sleep(1000)
+    stop()
+  `)
   assert.match(out.stderr, 'waiting')
 })
 
test/index.test.js
@@ -486,10 +486,4 @@ test('timeout() expiration works', async () => {
   assert.is(signal, undefined)
 })
 
-test('timeout() with string durations works', async () => {
-  assert.is($`:`.timeout('2s')._timeout, 2000)
-  assert.is($`:`.timeout('500ms')._timeout, 500)
-  assert.throws(() => $`:`.timeout('100'))
-})
-
 test.run()
test/util.test.js
@@ -14,7 +14,14 @@
 
 import { suite } from 'uvu'
 import * as assert from 'uvu/assert'
-import { exitCodeInfo, randomId, noop, isString, quote } from '../build/util.js'
+import {
+  exitCodeInfo,
+  randomId,
+  noop,
+  isString,
+  quote,
+  parseDuration,
+} from '../build/util.js'
 
 const test = suite('util')
 
@@ -43,4 +50,11 @@ test('quote()', () => {
   assert.ok(quote(`'\f\n\r\t\v\0`) === `$'\\'\\f\\n\\r\\t\\v\\0'`)
 })
 
+test('duration parsing works', () => {
+  assert.is(parseDuration(1000), 1000)
+  assert.is(parseDuration('2s'), 2000)
+  assert.is(parseDuration('500ms'), 500)
+  assert.throws(() => parseDuration('100'))
+})
+
 test.run()