Repository: alecthomas/kong
Pull request #507 (2025-03-07 18:58:28)

feat: Allow Kong to exit with semantic exit codes
feat: Allow Kong to exit with semantic exit codes

At Block, we've instrumented a number of commandline tools and set SLOs on some tools' reliability. To do that effectively, we had to partition usage errors from reliability issues. We looked at [prior art](https://github.com/square/exit?tab=readme-ov-file#reserved-codes-and-prior-art) and, taking inspiration from HTTP, defined [a set of semantic exit codes](https://github.com/square/exit?tab=readme-ov-file#about) in ranges: 80-99 for user errors, 100-119 for system errors.

We've been wrapping errors in `exit.Error` at whatever level of the stack can tell which class an error is and unwrapping them at exit (`os.Exit(exit.FromError(err))`).

This adds support for semantic exit codes to Kong, to `FatalIfErrorf`, which is used internally by `kong.Parse` and often used in Kong applications.

It also exits 80 (usage error) instead of 1 (generic error) when Kong parses a syntactically or structurally invalid command line.

boblail: @alecthomas, makes sense, thanks!

I've updated this PR (and [square/exit](https://github.com/square/exit/pull/95)) accordingly.
boblail: > This functionality should also be documented in the Go docs/README, perhaps by making the interface public.

I made the interface public, gave it a docstring, and referenced it in FatalIfErrorf's doc string.

Do you think it warrants a mention in the README in addition?

Patch:
diff --git a/error.go b/error.go
index 18225ef..33a4e14 100644
--- a/error.go
+++ b/error.go
@@ -10,3 +10,6 @@ type ParseError struct {
 
 // Unwrap returns the original cause of the error.
 func (p *ParseError) Unwrap() error { return p.error }
+
+// ExitCode returns the status that Kong should exit with if it fails with a ParseError.
+func (p *ParseError) ExitCode() int { return exitUsageError }
diff --git a/exit.go b/exit.go
new file mode 100644
index 0000000..4925f48
--- /dev/null
+++ b/exit.go
@@ -0,0 +1,32 @@
+package kong
+
+import "errors"
+
+const (
+	exitOk    = 0
+	exitNotOk = 1
+
+	// Semantic exit codes from https://github.com/square/exit?tab=readme-ov-file#about
+	exitUsageError = 80
+)
+
+// ExitCoder is an interface that may be implemented by an error value to
+// provide an integer exit code. The method ExitCode should return an integer
+// that is intended to be used as the exit code for the application.
+type ExitCoder interface {
+	ExitCode() int
+}
+
+// exitCodeFromError returns the exit code for the given error.
+// If err implements the exitCoder interface, the ExitCode method is called.
+// Otherwise, exitCodeFromError returns 0 if err is nil, and 1 if it is not.
+func exitCodeFromError(err error) int {
+	var e ExitCoder
+	if errors.As(err, &e) {
+		return e.ExitCode()
+	} else if err == nil {
+		return exitOk
+	}
+
+	return exitNotOk
+}
diff --git a/kong.go b/kong.go
index a5e3d99..e000553 100644
--- a/kong.go
+++ b/kong.go
@@ -311,35 +311,35 @@ func (k *Kong) extraFlags() []*Flag {
 // invalid one, which will report a normal error).
 func (k *Kong) Parse(args []string) (ctx *Context, err error) {
 	ctx, err = Trace(k, args)
-	if err != nil {
+	if err != nil { // Trace is not expected to return an err
 		return nil, err
 	}
 	if ctx.Error != nil {
 		return nil, &ParseError{error: ctx.Error, Context: ctx}
 	}
 	if err = k.applyHook(ctx, "BeforeReset"); err != nil {
-		return nil, &ParseError{error: err, Context: ctx}
+		return nil, err
 	}
 	if err = ctx.Reset(); err != nil {
 		return nil, &ParseError{error: err, Context: ctx}
 	}
 	if err = k.applyHook(ctx, "BeforeResolve"); err != nil {
-		return nil, &ParseError{error: err, Context: ctx}
+		return nil, err
 	}
 	if err = ctx.Resolve(); err != nil {
 		return nil, &ParseError{error: err, Context: ctx}
 	}
 	if err = k.applyHook(ctx, "BeforeApply"); err != nil {
-		return nil, &ParseError{error: err, Context: ctx}
+		return nil, err
 	}
-	if _, err = ctx.Apply(); err != nil {
-		return nil, &ParseError{error: err, Context: ctx}
+	if _, err = ctx.Apply(); err != nil { // Apply is not expected to return an err
+		return nil, err
 	}
 	if err = ctx.Validate(); err != nil {
 		return nil, &ParseError{error: err, Context: ctx}
 	}
 	if err = k.applyHook(ctx, "AfterApply"); err != nil {
-		return nil, &ParseError{error: err, Context: ctx}
+		return nil, err
 	}
 	return ctx, nil
 }
@@ -428,13 +428,15 @@ func (k *Kong) Errorf(format string, args ...any) *Kong {
 	return k
 }
 
-// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with a non-zero status.
+// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with status 1.
 func (k *Kong) Fatalf(format string, args ...any) {
 	k.Errorf(format, args...)
 	k.Exit(1)
 }
 
 // FatalIfErrorf terminates with an error message if err != nil.
+// If the error implements the ExitCoder interface, the ExitCode() method is called and
+// the application exits with that status. Otherwise, the application exits with status 1.
 func (k *Kong) FatalIfErrorf(err error, args ...any) {
 	if err == nil {
 		return
@@ -455,7 +457,8 @@ func (k *Kong) FatalIfErrorf(err error, args ...any) {
 			fmt.Fprintln(k.Stdout)
 		}
 	}
-	k.Fatalf("%s", msg)
+	k.Errorf("%s", msg)
+	k.Exit(exitCodeFromError(err))
 }
 
 // LoadConfig from path using the loader configured via Configuration(loader).


Test patch:
diff --git a/help_test.go b/help_test.go
index ccbfb63..9162d60 100644
--- a/help_test.go
+++ b/help_test.go
@@ -786,10 +786,11 @@ func TestUsageOnError(t *testing.T) {
 		Flag string `help:"A required flag." required`
 	}
 	w := &strings.Builder{}
+	exitCode := -1
 	p := mustNew(t, &cli,
 		kong.Writers(w, w),
 		kong.Description("Some description."),
-		kong.Exit(func(int) {}),
+		kong.Exit(func(code int) { exitCode = code }),
 		kong.UsageOnError(),
 	)
 	_, err := p.Parse([]string{})
@@ -806,6 +807,7 @@ Flags:
 test: error: missing flags: --flag=STRING
 `
 	assert.Equal(t, expected, w.String())
+	assert.Equal(t, 80, exitCode)
 }
 
 func TestShortUsageOnError(t *testing.T) {
@@ -813,10 +815,11 @@ func TestShortUsageOnError(t *testing.T) {
 		Flag string `help:"A required flag." required`
 	}
 	w := &strings.Builder{}
+	exitCode := -1
 	p := mustNew(t, &cli,
 		kong.Writers(w, w),
 		kong.Description("Some description."),
-		kong.Exit(func(int) {}),
+		kong.Exit(func(code int) { exitCode = code }),
 		kong.ShortUsageOnError(),
 	)
 	_, err := p.Parse([]string{})
@@ -829,6 +832,7 @@ Run "test --help" for more information.
 test: error: missing flags: --flag=STRING
 `
 	assert.Equal(t, expected, w.String())
+	assert.Equal(t, 80, exitCode)
 }
 
 func TestCustomShortUsageOnError(t *testing.T) {
@@ -840,10 +844,11 @@ func TestCustomShortUsageOnError(t *testing.T) {
 		fmt.Fprintln(ctx.Stdout, "🤷 wish I could help")
 		return nil
 	}
+	exitCode := -1
 	p := mustNew(t, &cli,
 		kong.Writers(w, w),
 		kong.Description("Some description."),
-		kong.Exit(func(int) {}),
+		kong.Exit(func(code int) { exitCode = code }),
 		kong.ShortHelp(shortHelp),
 		kong.ShortUsageOnError(),
 	)
@@ -856,4 +861,5 @@ func TestCustomShortUsageOnError(t *testing.T) {
 test: error: missing flags: --flag=STRING
 `
 	assert.Equal(t, expected, w.String())
+	assert.Equal(t, 80, exitCode)
 }