Skip to content

cli: distinct exit codes per failure category (MissingAuth vs BuildFailed vs RunFailed all collide on 1) #1266

Description

@DaveHanns

Problem

apify-cli's CommandExitCodes enum at src/lib/consts.ts:74-91 collapses three semantically-distinct failure categories to exit code 1, with two more pairs colliding on codes 3 and 5:

// src/lib/consts.ts:74-91
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
export enum CommandExitCodes {
    BuildFailed = 1,
    RunFailed = 1,
    MissingAuth = 1,
    BuildTimedOut = 2,
    BuildAborted = 3,
    RunAborted = 3,
    NoFilesToPush = 4,
    InvalidInput = 5,
    InvalidActorJson = 5,
    NotFound = 250,
    NotImplemented = 255,
}

The eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values directive is the canary — the collisions are deliberate, but they force every calling script or agent into brittle stderr string-matching to answer the two most common operational questions:

  1. "Did this fail because of auth, or because of code?" — auth is non-transient (needs re-auth); code failures may be transient (retry).
  2. "Should I retry, or fail-fast?"MissingAuth is non-transient, BuildFailed may be a network blip, RunFailed is usually deterministic.

What the standard says

POSIX only mandates 0 = success, non-zero = failure. Codes 1-125 are per-tool convention. The de-facto modern pattern (gh, AWS CLI, curl, git, npm) is distinct exit codes per error category. BSD sysexits.h reserves 64-78; EX_NOPERM = 77 is the canonical "missing auth" mapping.

  • GitHub CLI: 0 success, 1 generic, 2 cancelled, 4 requires authentication — the most widely-copied modern pattern.
  • AWS CLI: distinct codes for user-error, service-error, syntax-error.
  • curl: 90+ distinct codes (6 DNS, 7 connect, 28 timeout, 67 bad user/password, etc.).

Proposal

Adopt a gh-CLI-style banded scheme with distinct codes per category. Sketch:

Code Constant Meaning
0 Success
1 GenericError fallback / unclassified
2 Cancelled user Ctrl+C
4 MissingAuth apify login needed
5 InvalidInput bad CLI usage / bad actor.json
10-19 Build band BuildFailed = 10, BuildTimedOut = 11, BuildAborted = 12, NoFilesToPush = 13
20-29 Run band RunFailed = 20, RunAborted = 21, RunTimedOut = 22
250 NotFound Actor/run/dataset not found (already used)
255 NotImplemented (already used)

oclif supports arbitrary exit codes via this.error({exit: N}), so no upstream framework changes needed.

Migration

This is a semver-major behavior change for any caller that pattern-matches on $? == 1. Options:

  1. Ship behind a APIFY_CLI_STRUCTURED_EXIT_CODES=1 opt-in env var for a release or two, then flip the default and remove the flag.
  2. Emit the classification on stderr as a machine-readable line ([apify] error-category: missing-auth) alongside the new exit code, so callers can pick either signal.

Companion improvement: on MissingAuth (or any non-zero exit) with --json, emit a structured {error, code, category} JSON envelope on stdout (currently prose-only — tracked separately as F14).

Reproducer

$ apify push  # in a project with no token set
# ... prose error about needing to login ...
$ echo $?
1
$ apify push  # in a project with intentional TypeScript error
# ... build error ...
$ echo $?
1

Two categorically different failures, indistinguishable to the caller.

Impact

Medium (P2). Every CI job or agent harness using apify-cli hits this. Cross-references F14 (same root cause on a different signal channel — the JSON error envelope).


Surfaced during an evaluation of Apify surfaces for agent-driven Actor development.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions