Skip to content

DAH-2593: stop the CLI Action layer from swallowing errors - #104

Merged
arhangel66 merged 5 commits into
mainfrom
DAH-2593-cli-error-propagation
Aug 7, 2026
Merged

DAH-2593: stop the CLI Action layer from swallowing errors#104
arhangel66 merged 5 commits into
mainfrom
DAH-2593-cli-error-propagation

Conversation

@arhangel66

Copy link
Copy Markdown
Collaborator

Continuation of DAH-2556. Every Action.execute() wrapped its work in a blanket
except Exception and returned ActionResult(ok=False, error=str(e)); the
command then did if not result.ok: ui.error(result.error); return, and a bare
return from a Click callback exits 0. handle_errors — the one place that
maps a failure to an exit code — never ran, because nothing was ever raised.

What changed

Actions stop swallowing. The exception reaches handle_errors, which
already renders it once and maps it to a code. Commands raise CliFailure only
for decisions they make themselves — the rm/command.py:30-65 pattern.

Three classes of except block are treated differently:

  • removed — a blanket except Exception around an SDK call with no decision
    inside it.
  • kept, per-item — the seven batch Actions that aggregate failed_huids
    (rm ×2, reboot, rsync, scp, volumes rm, schedules rm). Removing
    their except would stop the loop at the first bad item. Their command raises
    once at the end, mirroring rm/command.py:122-134.
  • kept, narrow — targeted catches that are not the anti-pattern:
    EditConfigAction's CalledProcessError, SetupSshKeyAction's local
    ssh-keygen, fund's bittensor/subtensor wrappers, a best-effort
    except OSError: pass around the ssh-key cache write.

Exit codes 3 and 6 became reachable. lium/sdk grows a typed
LiumPermissionError raised on 403 at both client.py status sites (_request
and the streaming/logs path). handle_errors gains an except LiumPermissionError clause before the general except LiumError — order
matters, or the general clause absorbs it — mapping 403 to
EXIT_PERMISSION_DENIED (6). The general LiumError branch moves from
EXIT_GENERAL_ERROR (1) to EXIT_API_ERROR (3), so LiumAuthError,
LiumNotFoundError, LiumRateLimitError and LiumServerError stop collapsing
to exit 1. The JSON envelope's code token stays "lium_error" — that token is a
separate contract from the exit number and test/test_topup_cli.py:96 pins it.

The rm asymmetry is preserved where it applies. --all against an empty
account stays an idempotent no-op (exit 0); a named target that matches nothing
is a failure (exit 5). reboot gains that asymmetry; rm already had it.

Sites the ticket did not name

  • ls was never fixed by DAH-2556. ls/command.py:100-102 still swallowed,
    so an API error while listing the market exited 0. DAH-2556 only fixed ls's
    --sort/Pareto-star bug.
  • ensure_config() (lium/cli/utils.py:783) discarded both Action results
    without even checking .ok, and gates ~15 commands.
  • fund without --alpha is the default path, not dead code despite the
    _legacy_tao_fund name. It held five silent exit-0 sites.
  • Nine commands no plan enumerated: update, theme, templates,
    ssh-keys list, ssh-keys sync, volumes new, volumes list, ps, logs.
    ps is the command an agent reaches for right after ls.
  • port-forward, found by the closing grep sweep: four named-target misses
    that printed an error and exited 0.

Behaviour changes worth reviewing

  • lium ps <named-target> that matches nothing now exits 5 in both output
    formats. --format json used to print [] and exit 0 even when the caller
    named one specific pod — the rm asymmetry read backwards. An unfiltered
    empty list still exits 0.
  • lium ssh on a dropped connection (ssh's own 255) now exits 4, matching what
    up's SSH path already did. A non-zero remote shell exit code still exits
    0 — that is the remote's business.
  • rsync, scp, volumes rm and schedules rm lose the ability to no-op
    against an empty account: they have no boolean --all to key the asymmetry
    off, only a targets string that may literally read "all". Only rm and
    reboot keep the no-op.
  • Every existing raise LiumError(...) site (_alpha_fund, topup) moves from
    exit 1 to exit 3. No test outside the out-of-scope trees asserted the old
    number.
  • lium config reset on an already-empty config stays a warning at exit 0 — the
    requested end state is already true, same reasoning as rm --all.

Known limits, deliberately not fixed here

  • lium ls --format json still renders failures as Rich text on stderr rather
    than the JSON error envelope: handle_errors keys the envelope off a kwarg
    named json_output, and ls names its flag output_format. The exit
    code
    — the thing an agent acts on — is fixed either way.
  • Three SDK spots (get_template, the GPU-shortname resolver, exec_all's
    per-pod wrapper) still turn any exception into None/an untyped dict, and
    _ensure_ssh_keys_registered still downgrades LiumError to a warning by
    design (ssh-key registration must not block a rental). A 403 arriving through
    those paths will not become exit 6.
  • lium provider … has its own exit-code taxonomy that collides with the main
    table.
    ProviderError + _EXIT_CODES in provider/_render.py:59-84 uses
    6 = config missing, 2 = auth, 5 = ssh, 7 = token-cache contention, while
    the main table reads 2 = configuration, 5 = pod-not-found, 6 =
    permission-denied. The two agree only on 3. Nothing in that tree swallows
    errors, so it needed no fix here — but the collision is real and worth its own
    ticket.

Tests

19 new behavioural tests, one per command family, in
test/test_agent_cli_contract.py plus two SDK-level 403 tests in
test/test_sdk_client.py. Each was verified RED against the unfixed code and
GREEN after.

test_up_fails_when_ssh_is_unavailable was rewritten: it asserted
result.ok is False from PrepareSSHAction, i.e. it encoded the very swallow
this PR removes. It now asserts pytest.raises(CliFailure) with
exit_code == EXIT_SSH_ERROR.

Full suite: 388 passed, 11 failed — the same 11 pre-existing failures as on
main (test/provider/ ×5, test_gpu_splitting_cli.py ×3,
test_release_binary_targets.py ×3), zero new. Note that no CI workflow runs
the general suite: ci.yml and release.yml only run
test/test_release_binary_targets.py.

@arhangel66
arhangel66 marked this pull request as ready for review August 6, 2026 04:15
@arhangel66
arhangel66 requested a review from taiberium August 6, 2026 04:15

@taiberium taiberium left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requested changes.

Comment thread lium/cli/utils.py
_emit_json_error("lium_error", str(e), EXIT_API_ERROR)
console.error(f"Error: {e}")
raise SystemExit(EXIT_GENERAL_ERROR)
raise SystemExit(EXIT_API_ERROR)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: the published exit table has no 3 or 6 and gives both to lium provider, where 6 means config missing. Needs a lium-docs PR alongside.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the published table stops at 5 and hands 3/6/7 to lium provider. Moved the source of truth into the constants block in lium/cli/utils.py: every code now carries its meaning inline, plus a note that lium provider keeps its own map where 3 and 6 mean portal error and missing config. The lium-docs side (adding rows 3 and 6 to docs/developers/cli/reference/index.md and rewording the "Two exceptions" note) is a separate PR I have not opened yet.

Comment thread lium/cli/utils.py
else:
console.error(f"Error: {e}")
raise SystemExit(EXIT_CONFIGURATION_ERROR)
except LiumPermissionError as e:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: PR #103 rewrites this same block, adding escape() to every branch. Whoever lands second merges by hand.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — I will take the manual merge if #103 lands first; this PR leaves the escape() branches alone.

Comment thread lium/cli/fund/command.py Outdated
ui.error("Bittensor library not installed")
ui.dim("Install with: pip install bittensor")
return
raise LiumError("Bittensor library not installed. Install with: pip install bittensor")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: a missing library is a config error (2), not an API error (3). PR #103 also rewrites these lines with CliFailure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it is a local environment problem — now CliFailure("bittensor_not_installed", ..., EXIT_CONFIGURATION_ERROR). Applied the same to the identical ImportError in the --alpha path so both exit 2.

Comment thread lium/cli/config/unset/command.py Outdated
if result.error:
ui.warning(result.error)
if not result.ok:
raise CliFailure("key_not_found", result.error, EXIT_GENERAL_ERROR)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: unsetting an absent key is the same idempotent case as rm --all, which you keep at exit 0.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — reverted to the previous behaviour: a warning and exit 0, since the key is already in the asked-for state.

Comment thread lium/cli/ssh/command.py Outdated
ui.error(error)
return
# "not found" is a miss; a pod that exists but cannot take a session is ssh's own failure.
if "not found" in error and "SSH connection" not in error:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: matching on error text breaks as soon as the wording changes. Better to return a code from parsing.parse.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — parsing.parse now returns tuple[dict | None, CliFailure | None] and builds the failure with its own code, so the command just re-raises it. validation.validate still returns text; say the word and I will move it over too.

@arhangel66
arhangel66 force-pushed the DAH-2593-cli-error-propagation branch from ba8b089 to a3e2b3b Compare August 6, 2026 07:12

@taiberium taiberium left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

All five comments are addressed. The one thing left is outside this repo: the exit-code table in lium-docs still stops at 5 and says 3/6/7 belong to lium provider only — that needs its own docs PR before this reads correctly.

@arhangel66
arhangel66 merged commit cc73468 into main Aug 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants