Skip to content
Errors and Exit Codes

Errors and Exit Codes

Package: github.com/therealbill/typesafe-go (import path typesafe), source errors.go. The jev CLI’s exit-code and error-JSON behavior is implemented in internal/cli/exit.go.

Error types

Every error type below satisfies errors.As and errors.Is through an Unwrap() error method: RateLimitError.Unwrap and TimeoutError.Unwrap return a pointer to their embedded struct (&e.APIError, &e.ConnectionError); ValidationError.Unwrap, ConnectionError.Unwrap, and ResponseValidationError.Unwrap return the wrapped Err field.

ValidationError

type ValidationError struct {
    Path string
    Err  error
}

ValidationError reports a request that failed client-side validation before any network call. Path names the offending field, for example "questions.tone.criteria".

MethodSignatureDescription
Errorfunc (e *ValidationError) Error() stringReturns "typesafe: invalid request: <Path>: <Err>".
Unwrapfunc (e *ValidationError) Unwrap() errorReturns Err.

APIError

type APIError struct {
    Status    int
    Body      []byte
    Headers   http.Header
    Endpoint  string
    RequestID string
}

APIError is returned when the API responds with a 4xx or 5xx status.

FieldTypeDescription
StatusintThe HTTP status code.
Body[]byteThe raw response body.
Headershttp.HeaderThe response headers.
EndpointstringThe method and path, for example "POST /v1/systemone".
RequestIDstringThe x-typesafe-request-id response header, if present.

(*APIError) Error

func (e *APIError) Error() string

Returns "typesafe: <Endpoint> returned <Status>", with ": <Message()>" appended when Message() is non-empty, and " (request id <RequestID>)" appended when RequestID is non-empty.

(*APIError) Message

func (e *APIError) Message() string

Message extracts a human-readable message from the body. It reads the API’s "detail" envelope in its string, object, and array forms, falls back to the "message" or "error" fields, and otherwise returns the trimmed body. Every one of those paths returns through the same final step, which coerces the message to valid UTF-8 (strings.ToValidUTF8(msg, "�"), replacing invalid byte sequences with �) and then truncates it to 200 bytes (maxMessageLen) without splitting a multi-byte UTF-8 rune. truncate walks back to the nearest rune boundary before cutting, appending "..." when a cut was made.

RateLimitError

type RateLimitError struct {
    APIError
    RetryAfter time.Duration
}

RateLimitError is an APIError with status 429. RetryAfter is the wait the server requested through Retry-After or retry-after-ms, or zero.

MethodSignatureDescription
Unwrapfunc (e *RateLimitError) Unwrap() errorReturns &e.APIError.

RateLimitError embeds APIError, so Status, Body, Headers, Endpoint, RequestID, Error(), and Message() are promoted from the embedded value.

ConnectionError

type ConnectionError struct {
    Err error
}

ConnectionError wraps a transport failure: DNS, dial, TLS, a reset connection, or a cancelled context.

MethodSignatureDescription
Errorfunc (e *ConnectionError) Error() stringReturns "typesafe: connection error: <Err>".
Unwrapfunc (e *ConnectionError) Unwrap() errorReturns Err.

TimeoutError

type TimeoutError struct {
    ConnectionError
    Timeout time.Duration
}

TimeoutError is a ConnectionError caused by the per-request timeout.

MethodSignatureDescription
Errorfunc (e *TimeoutError) Error() stringReturns "typesafe: request timed out after <Timeout>: <Err>", where <Err> is the embedded ConnectionError’s Err field.
Unwrapfunc (e *TimeoutError) Unwrap() errorReturns &e.ConnectionError.

TimeoutError embeds ConnectionError, so its Err field is promoted from the embedded value.

ResponseValidationError

type ResponseValidationError struct {
    FieldPath string
    Err       error
}

ResponseValidationError reports a 2xx response whose body did not match the expected shape. FieldPath is dotted, for example "answers.tone.confidence".

MethodSignatureDescription
Errorfunc (e *ResponseValidationError) Error() stringReturns "typesafe: invalid response field \"<FieldPath>\": <Err>".
Unwrapfunc (e *ResponseValidationError) Unwrap() errorReturns Err.

ErrMissingAPIKey

var ErrMissingAPIKey = errors.New("typesafe: API key is required: pass WithAPIKey or set TYPESAFE_API_KEY")

ErrMissingAPIKey is returned by NewClient when no API key is configured.

Error predicate functions

FunctionSignatureDescription
IsAuthErrorfunc IsAuthError(err error) boolReports whether err is an APIError with status 401 or 403. Implemented as errors.As(err, &api) && (api.Status == 401 || api.Status == 403).
IsRateLimitedfunc IsRateLimited(err error) boolReports whether err is a RateLimitError. Implemented as errors.As(err, &rl).
IsRetryablefunc IsRetryable(err error) boolReports whether the default RetryPolicy would retry err. Implemented as DefaultRetryPolicy().retryable(nil, err).

jev CLI: exit codes

Source: internal/cli/exit.go.

const (
    ExitOK          = 0
    ExitUsage       = 1   // bad flags, unreadable or invalid request JSON, missing API key, or an unrecognized error
    ExitValidation  = 2   // request failed client-side validation
    ExitAuth        = 3   // 401 or 403
    ExitRequest     = 4   // other 4xx: 400, 404, 422
    ExitRateLimit   = 5   // 429 after retries
    ExitServer      = 6   // 5xx after retries, or an unreadable 2xx body
    ExitConnection  = 7   // connection failure or timeout
    ExitFlagged     = 8   // jev review: at least one unit is failing; see the report
    ExitInterrupted = 130 // the context was cancelled, conventionally by SIGINT
)

The "kind" field of the error JSON (below) names the case more precisely than the exit code: code 1 covers two distinct kind values, "usage" and "internal".

ConstantCodeCondition
ExitOK0Success.
ExitUsage1Bad flags, unreadable or invalid request JSON, missing API key, or an unrecognized error. Covers kind "usage" and kind "internal"; see the classification table below.
ExitValidation2Request failed client-side validation.
ExitAuth3401 or 403.
ExitRequest4Other 4xx: 400, 404, 422.
ExitRateLimit5429 after retries.
ExitServer65xx after retries, or an unreadable 2xx body.
ExitConnection7Connection failure or timeout.
ExitFlagged8jev review only: at least one unit is failing. Covers kind "flagged".
ExitInterrupted130The context was cancelled, conventionally by SIGINT.

Code 8 and kind "flagged" belong to jev review alone; no other subcommand produces them. jev review does not route a failing unit through classify: a unit whose Jev call itself failed with an API or transport error exits with that error’s own classified code (3 to 7); a unit that ran but has an unaccepted flag, a missing file, a missing spec heading, or a timeout, with no call error, exits 8 instead. See the jev review reference for the full rule and the report shape.

jev CLI: error classification

classify(err error) (int, string) in internal/cli/exit.go maps a returned error to an exit code and to the "kind" string carried in the error JSON (below). The checks run in this order; the first match wins, each using errors.As/errors.Is so a wrapped error matches whenever it satisfies the target type or value via Unwrap().

OrderGo error type/value checkedConditionExit codekind
1errors.Is(err, context.Canceled)anyExitInterrupted (130)"interrupted"
2*usageError, or errors.Is(err, typesafe.ErrMissingAPIKey)anyExitUsage (1)"usage"
3*typesafe.ValidationErroranyExitValidation (2)"validation"
4*typesafe.RateLimitErroranyExitRateLimit (5)"rate_limit"
5*typesafe.APIErrorStatus == 401 || Status == 403ExitAuth (3)"auth"
6*typesafe.APIErrorStatus >= 500ExitServer (6)"server"
7*typesafe.APIErrorotherwise (e.g. 400, 404, 422)ExitRequest (4)"request"
8*typesafe.ConnectionErrorany (includes *typesafe.TimeoutError, which embeds it)ExitConnection (7)"connection"
9*typesafe.ResponseValidationErroranyExitServer (6)"invalid_response"
Defaultnone of the aboven/aExitUsage (1)"internal"

*usageError (order 2) marks errors caused by how the CLI itself was invoked. See Usage errors versus validation errors below. The final, unlabeled row is the fallback for an error that reached fail but matched none of the typed cases; it is reported with kind "internal" rather than "usage", even though both share exit code 1.

The "interrupted" versus "connection" split is made by the CLI, not by the library. At the library level both cases are the same Go type: when the parent context passed to SystemOne/ListModels is done (wrapTransportError in transport.go checks parent.Err() != nil first), the request fails with &typesafe.ConnectionError{Err: parent.Err()}, the same type whatever the cause, carrying whichever error that context holds. classify is what separates them:

  • A SIGINT-cancelled context (Main installs a signal.NotifyContext on os.Interrupt and syscall.SIGTERM) yields context.Canceled, which the order-1 errors.Is(err, context.Canceled) check catches → "interrupted" (130).
  • A parent context that instead hit its own deadline yields context.DeadlineExceeded, which is not context.Canceled, so it falls through to the order-8 *typesafe.ConnectionError check → "connection" (7).

At the library level the same distinction comes from errors.Is(err, context.Canceled). errors.As against *typesafe.ConnectionError alone does not separate the two cases. A *typesafe.TimeoutError is a third, unrelated case: it is produced only by the per-attempt timeout from WithTimeout/WithRequestTimeout, never by the caller’s own context.

Usage errors versus validation errors

The CLI rejects four kinds of input before calling the library: malformed top-level request JSON (missing "questions", missing "state"), a blank or whitespace-only flag-mode question key, an unknown field inside a JSON question object, and --state given without any --noul/--choice/--score. Each surfaces as *usageError (exit code 1, kind "usage"). *typesafe.ValidationError (exit code 2, kind "validation") is a separate case: the library’s own semantic validation of an already-well-formed Questions map (for example a Choice with zero labels), performed after the CLI has parsed the request.

Verified against the built binary:

$ ./bin/jev ask --state "hi" --noul billing="is this about billing" \
    --api-key dummy --base-url http://127.0.0.1:1 --timeout 1s --max-retries 0
{"error":{"kind":"connection","message":"typesafe: connection error: Post \"http://127.0.0.1:1/v1/systemone\": dial tcp 127.0.0.1:1: connect: connection refused"}}
jev: typesafe: connection error: Post "http://127.0.0.1:1/v1/systemone": dial tcp 127.0.0.1:1: connect: connection refused
$ echo $?
7

jev CLI: error JSON shape

On failure, fail (internal/cli/exit.go) writes one JSON object to stdout:

{
  "error": {
    "kind": "<string>",
    "status": "<int, omitted if 0>",
    "request_id": "<string, omitted if empty>",
    "message": "<string>"
  }
}
FieldTypePresenceValue
kindstringAlways.The kind returned by classify(err).
statusintOmitted when 0 (json:",omitempty").(*typesafe.APIError).Status, when err is (or wraps) an *typesafe.APIError.
request_idstringOmitted when empty (json:",omitempty").(*typesafe.APIError).RequestID, when err is (or wraps) an *typesafe.APIError.
messagestringAlways.err.Error() by default; replaced with (*typesafe.APIError).Message() when err is (or wraps) an *typesafe.APIError and Message() is non-empty.

fail also writes one line to stderr, regardless of --pretty:

jev: <err>

<err> is sanitize(err.Error()): sanitize (internal/cli/exit.go) replaces every control character except tab with a \xNN escape, so a response body containing terminal escape sequences cannot manipulate the user’s terminal. Newline (\n) is one of the escaped characters, so a multi-line error message is collapsed onto this single stderr line (each newline appears as \x0a) rather than spanning multiple lines. This sanitization applies only to this stderr line, not to the stdout JSON, which is already safely JSON-encoded (encoding/json escapes control characters on its own).

Verified against the built binary (no *typesafe.APIError involved, so status and request_id are both absent):

$ env -u TYPESAFE_API_KEY ./bin/jev models
{"error":{"kind":"usage","message":"typesafe: API key is required: pass WithAPIKey or set TYPESAFE_API_KEY"}}
jev: typesafe: API key is required: pass WithAPIKey or set TYPESAFE_API_KEY
$ echo $?
1

jev CLI: non-classified failures

runMain (internal/cli/root.go) calls root.ExecuteContext(ctx) and, when the returned error is not a *ExitError (for example a Cobra-level flag-parsing or unknown-command error that never reached fail/classify), it writes both streams, mirroring a classified failure:

  • stdout: the same error JSON envelope shape as fail produces, with kind hardcoded to "usage" (this path does not call classify):

    {"error": {"kind": "usage", "message": "<error>"}}
  • stderr:

    jev: <sanitized error>
    Run 'jev --help' for usage.

The stdout JSON envelope is therefore written on every non-zero exit, including a Cobra-level flag or command error rejected before any subcommand runs. This path returns exit code 1 (ExitUsage).