ADR-028: CLI Error Handling and Exit Codes
Status
Implemented
Date
2025-12-07
Deciders
- Norbert Bede
- Claude (AI Assistant)
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:
- Provides actionable error messages
- Uses proper exit codes for scripting
- Validates database state before apply (pre-flight check)
- Supports both human and machine-readable output
Reference: Command Line Interface Guidelines
Decision Drivers
- Scriptability: Exit codes must differentiate error types for automation
- Debuggability: Errors must include context, cause, and suggestions
- Consistency: Same error handling across all commands
- Pre-flight validation: Catch conflicts before attempting changes
- Machine-readable: Support
--jsonfor programmatic consumption
Considered Options
- Basic error handling - Simple error messages with exit code 1
- Structured error handling - Exit code convention + structured messages + pre-flight checks
- 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
- All commands return correct exit codes (testable via
echo $?) - Error messages include context and suggestions
- Pre-flight checks prevent known conflicts
--jsonflag produces machine-readable output
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.
- Good, because minimal implementation effort
- Bad, because no differentiation between error types
- Bad, because not scriptable
- Bad, because no pre-flight validation
Option 2: Structured Error Handling (Chosen)
Exit code convention + structured messages + pre-flight checks.
- Good, because follows clig.dev guidelines
- Good, because scriptable with exit codes
- Good, because human-readable and machine-readable
- Good, because pre-flight catches conflicts early
- Neutral, because moderate implementation effort
Option 3: Full Error Framework
Custom exception hierarchy + error codes + recovery suggestions.
- Good, because comprehensive error handling
- Good, because could support automatic recovery
- Bad, because over-engineered for current needs
- Bad, because significant implementation effort
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...
Related ADRs
- ADR-004 - Table creation with distribution formats
- ADR-005 - Migration scripts
- ADR-019 - CLI command structure
- ADR-021 - RAG Architecture