ADR-028: CLI Error Handling and Exit Codes

Status

Implemented

Date

2025-12-07

Deciders

Context and Problem Statement

The CLI generates output in multiple distribution formats (migration scripts, 2Pack, REST API calls). All three can fail during apply for the same reasons - database state conflicts that dry-run validation cannot detect. Currently, error handling is inconsistent and doesn't follow CLI best practices. We need a standardized approach that:

  1. Provides actionable error messages
  2. Uses proper exit codes for scripting
  3. Validates database state before apply (pre-flight check)
  4. Supports both human and machine-readable output

Reference: Command Line Interface Guidelines

Decision Drivers

Considered Options

  1. Basic error handling - Simple error messages with exit code 1
  2. Structured error handling - Exit code convention + structured messages + pre-flight checks
  3. Full error framework - Custom exception hierarchy + error codes + recovery suggestions

Decision Outcome

Chosen option: "Structured error handling", because it balances simplicity with the needs of both human users and automation scripts, following clig.dev guidelines.

Confirmation

Exit Code Convention

Code Constant Meaning Example
0 SUCCESS Operation completed Table created
1 ERROR Application error Table already exists
2 USAGE_ERROR Invalid arguments Missing required --name
3 CONNECTION_ERROR Connection failed Database unreachable
4 PARTIAL_FAILURE Some operations failed 3 of 5 columns created

Error Message Format

Human-Readable (default)

$ idempiere-cli dict add table --name XX_MyTable --apply

ERROR: Table 'XX_MyTable' already exists in database

  Context:
    Table ID: 1000123
    Created: 2024-01-15 by SuperUser

  Suggestion:
    - Use 'dict update table' to modify existing table
    - Use '--force' to drop and recreate (DANGEROUS)
    - Use '--skip-existing' to continue without this table

  See: https://wiki.idempiere.org/en/Table_and_Column

Machine-Readable (--json)

{
  "success": false,
  "exitCode": 1,
  "error": {
    "code": "TABLE_EXISTS",
    "message": "Table 'XX_MyTable' already exists in database",
    "context": {
      "tableId": 1000123,
      "tableName": "XX_MyTable",
      "createdBy": "SuperUser",
      "createdAt": "2024-01-15"
    },
    "suggestions": [
      "Use 'dict update table' to modify existing table",
      "Use '--force' to drop and recreate",
      "Use '--skip-existing' to continue without this table"
    ],
    "documentation": "https://wiki.idempiere.org/en/Table_and_Column"
  }
}

Pre-Flight Validation

Before applying any changes, CLI performs database state checks:

┌─────────────────────────────────────────────────────────────────┐
│                 PRE-FLIGHT CHECK (before --apply)               │
│                                                                  │
│   1. Connect to target database                                  │
│   2. Check: Does table/column already exist?                     │
│   3. Check: Are referenced tables/columns present?               │
│   4. Check: Are there FK/constraint conflicts?                   │
│   5. If conflict → STOP with actionable message                  │
│   6. If clear → proceed with apply                               │
└─────────────────────────────────────────────────────────────────┘

Pre-Flight Check Matrix

Check Query Error Code Message
Table exists SELECT AD_Table_ID FROM AD_Table WHERE TableName=? TABLE_EXISTS Table 'X' already exists
Column exists SELECT AD_Column_ID FROM AD_Column WHERE ... COLUMN_EXISTS Column 'X' already exists
FK reference missing SELECT AD_Table_ID FROM AD_Table WHERE TableName=? FK_REFERENCE_MISSING Referenced table 'X' not found
Element missing SELECT AD_Element_ID FROM AD_Element WHERE ColumnName=? ELEMENT_MISSING Element 'X' not found
LLM unavailable embeddingModel.embed("ping") LLM_UNAVAILABLE Ollama not reachable
RAG Vector DB unavailable embeddingStore.isAvailable() RAG_VECTORDB_UNAVAILABLE pgvector not available, RAG_VECTORDB_PG_* not set

Failure Handling by Format

All three distribution formats fail for the same reasons. The pre-flight check catches them early:

Format Failure Point With Pre-Flight
Migration psql execution Caught before SQL generation
2Pack PackIn process Caught before XML generation
REST HTTP 400/500 Caught before API calls

Implementation

Exit Codes Class

public final class ExitCodes {
    public static final int SUCCESS = 0;
    public static final int ERROR = 1;
    public static final int USAGE_ERROR = 2;
    public static final int CONNECTION_ERROR = 3;
    public static final int PARTIAL_FAILURE = 4;

    private ExitCodes() {}
}

Error Code Enum

Error codes include sample messages and suggestions directly in the enum:

public enum CliErrorCode {
    TABLE_EXISTS(
        "Table already exists in database",
        "https://wiki.idempiere.org/en/Table_and_Column_(Window_ID-100)",
        "Table '%s' already exists in database",
        "Use 'dict table update' to modify existing table"),
    LLM_UNAVAILABLE(
        "Cannot connect to LLM service (Ollama)",
        "https://ollama.ai/download",
        "Cannot connect to Ollama at %s",
        "Start Ollama: ollama serve"),
    // ... other codes

    // Methods
    public String getSampleMessage() { ... }  // Pre-filled demo message
    public String getSuggestion() { ... }     // Default suggestion
}

Structured Error Class

public record CliError(
    CliErrorCode code,     // Enum with built-in metadata
    String message,        // Human-readable message
    Map<String, Object> context,  // Contextual data
    List<String> suggestions,     // Actionable suggestions
    String documentation   // Wiki URL (from code.getDocumentation())
) {
    public void print(boolean json) {
        if (json) {
            System.out.println(toJson());
        } else {
            printHumanReadable();
        }
    }
}

Pre-Flight Service

@ApplicationScoped
public class PreFlightService {

    public PreFlightResult check(TableDefinition table) {
        List<CliError> errors = new ArrayList<>();

        // Check table exists
        if (tableExists(table.getName())) {
            errors.add(new CliError(
                "TABLE_EXISTS",
                "Table '" + table.getName() + "' already exists",
                Map.of("tableId", getTableId(table.getName())),
                List.of(
                    "Use 'dict update table' to modify",
                    "Use '--force' to recreate",
                    "Use '--skip-existing' to skip"
                ),
                "https://wiki.idempiere.org/en/Table_and_Column"
            ));
        }

        // Check referenced tables exist
        for (Column col : table.getColumns()) {
            if (col.hasReference() && !tableExists(col.getReferencedTable())) {
                errors.add(new CliError(
                    "FK_REFERENCE_MISSING",
                    "Referenced table '" + col.getReferencedTable() + "' not found",
                    Map.of("column", col.getName()),
                    List.of("Create referenced table first"),
                    "https://wiki.idempiere.org/en/Table_and_Column"
                ));
            }
        }

        return new PreFlightResult(errors.isEmpty(), errors);
    }
}

Pros and Cons of the Options

Option 1: Basic Error Handling

Simple error messages with exit code 1.

Option 2: Structured Error Handling (Chosen)

Exit code convention + structured messages + pre-flight checks.

Option 3: Full Error Framework

Custom exception hierarchy + error codes + recovery suggestions.

More Information

clig.dev Guidelines Applied

Guideline Implementation
"If it fails, fail noisily" Always print error with context
"Exit codes" Standardized 0-4 codes
"Show full error messages" Context + suggestion + docs link
"Prefer stderr for errors" Use ConsoleOutput.error()
"Consider JSON output" --json flag support

RAG Pre-Flight Checks

For knowledge ingestion commands, additional pre-flight checks verify AI infrastructure:

@ApplicationScoped
public class PreFlightService {

    // Check embedding model (Ollama) is reachable
    public PreFlightResult checkEmbeddingModel() {
        if (!modelProvider.isReachable()) {
            return PreFlightResult.failure(CliError.builder(CliErrorCode.LLM_UNAVAILABLE)
                .message("Cannot connect to embedding model service")
                .suggestion("Start Ollama: ollama serve")
                .suggestion("Ensure model is pulled: ollama pull nomic-embed-text")
                .build());
        }
        return PreFlightResult.ok();
    }

    // Check vector database (pgvector) is available
    public PreFlightResult checkVectorDatabase() { ... }

    // Combined check for RAG operations
    public PreFlightResult checkRagPrerequisites() { ... }
}

Usage in knowledge ingest:

$ idempiere-cli knowledge ingest --source ad_metadata
Running preflight checks...
✓ Preflight checks passed
Initializing embedding model...

References

Path: /docs/developers/architecture/idempiere-hub/028-cli-error-handling