fix: exit 75 for transient failures (#690)

Every failure exited 1, so callers could only retry blindly:
deterministic failures (no provider detected, missing start command)
were run and logged three times by builderv3 before giving up.

Transient failures are now typed as mise.TemporaryError and map to exit
75 (EX_TEMPFAIL): transport errors, 429, 408, 5xx, and truncated
bodies. A 404 for a pinned mise version and a malformed archive stay
deterministic, since retrying them cannot succeed. The mise download
also had no response status check at all, so a 5xx body was written to
disk and later failed as "binary not found in archive".

Carrying that across the process boundary meant giving
GenerateBuildPlan an error return: a mise install failure was
previously swallowed into BuildResult{Success: false} and exited 1
along with every app-level failure. The error is non-nil only for
transient failures, so !Success stays on exit 1.

The download gets an http.Client timeout, since http.DefaultClient has
none and a stalled connection would hang the build forever. It
deliberately does not retry in-process: the caller already retries the
whole command, and retrying here would multiply that and delay the
signal.

Also writes --info-out even when the build result is unsuccessful, so
callers can read {"success": false, "logs": [...]} instead of scraping
stdout.
This commit is contained in:
Ed Ganiukov
2026-08-12 14:46:23 +02:00
committed by GitHub
parent 28751edc70
commit 01ecfada39
15 changed files with 362 additions and 65 deletions
+6 -6
View File
@@ -73,7 +73,7 @@ var BuildCommand = &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
buildResult, app, env, err := GenerateBuildResultForCommand(cmd)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, exitCodeForError(err))
}
if !cmd.Bool("dump-llb") {
@@ -81,19 +81,19 @@ var BuildCommand = &cli.Command{
}
if !buildResult.Success {
os.Exit(1)
os.Exit(ExitCodeFailure)
return nil
}
if cmd.Bool("show-plan") && !cmd.Bool("dump-llb") {
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
serializedPlan, err := json.MarshalIndent(planMap, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
core.PrettyPrintSectionHeader(os.Stdout, "Generated railpack-plan.json")
@@ -102,7 +102,7 @@ var BuildCommand = &cli.Command{
err = validateSecrets(buildResult.Plan, env)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
secretsHash := getSecretsHash(env)
@@ -124,7 +124,7 @@ var BuildCommand = &cli.Command{
NoCache: cmd.Bool("no-cache"),
})
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
return nil
+24 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/railwayapp/railpack/core"
a "github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/config"
"github.com/railwayapp/railpack/core/mise"
"github.com/railwayapp/railpack/core/plan"
"github.com/railwayapp/railpack/internal/utils"
"github.com/urfave/cli/v3"
@@ -15,6 +16,24 @@ import (
var Version string // This will be set by main
const (
// the command failed for a reason that will not change on a retry
ExitCodeFailure = 1
// EX_TEMPFAIL: the command failed for a transient reason (e.g. a network error
// downloading mise) and is worth retrying. Platforms that run Railpack in a
// build pipeline key their retry logic on this.
ExitCodeTransient = 75
)
func exitCodeForError(err error) int {
if mise.IsTemporary(err) {
return ExitCodeTransient
}
return ExitCodeFailure
}
func commonPlanFlags() []cli.Flag {
return []cli.Flag{
&cli.StringSliceFlag{
@@ -49,7 +68,7 @@ func GenerateBuildResultForCommand(cmd *cli.Command) (*core.BuildResult, *a.App,
directory := cmd.Args().First()
if directory == "" {
return nil, nil, nil, cli.Exit("directory argument is required", 1)
return nil, nil, nil, cli.Exit("directory argument is required", ExitCodeFailure)
}
app, err := a.NewApp(directory)
@@ -83,7 +102,10 @@ func GenerateBuildResultForCommand(cmd *cli.Command) (*core.BuildResult, *a.App,
ErrorMissingStartCommand: cmd.Bool("error-missing-start"),
}
buildResult := core.GenerateBuildPlan(app, env, generateOptions)
buildResult, err := core.GenerateBuildPlan(app, env, generateOptions)
if err != nil {
return nil, nil, nil, err
}
return buildResult, app, env, nil
}
+22
View File
@@ -2,9 +2,11 @@ package cli
import (
"encoding/json"
"fmt"
"testing"
"github.com/railwayapp/railpack/core/config"
"github.com/railwayapp/railpack/core/mise"
"github.com/railwayapp/railpack/core/plan"
"github.com/stretchr/testify/require"
)
@@ -31,6 +33,26 @@ func TestAddSchemaToPlanMap_IncludesSchemaAndPreservesPlan(t *testing.T) {
require.Equal(t, baseMap, outMap)
}
func TestExitCodeForError(t *testing.T) {
temporary := &mise.TemporaryError{URL: "https://example.com/mise.tar.gz", Err: fmt.Errorf("i/o timeout")}
tests := []struct {
name string
err error
expected int
}{
{name: "temporary", err: temporary, expected: ExitCodeTransient},
{name: "wrapped temporary", err: fmt.Errorf("failed to ensure mise is installed: %w", temporary), expected: ExitCodeTransient},
{name: "deterministic", err: fmt.Errorf("no start command was found"), expected: ExitCodeFailure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, exitCodeForError(tt.err))
})
}
}
func TestAddSchemaToPlanMap_NilPlan(t *testing.T) {
outMap, err := addSchemaToPlanMap(nil)
require.NoError(t, err)
+7 -7
View File
@@ -37,7 +37,7 @@ var InfoCommand = &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
buildResult, _, _, err := GenerateBuildResultForCommand(cmd)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, exitCodeForError(err))
}
format := cmd.String("format")
@@ -52,11 +52,11 @@ var InfoCommand = &cli.Command{
if cmd.Bool("show-plan") {
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
serializedPlan, err := json.MarshalIndent(planMap, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
core.PrettyPrintSectionHeader(&rendered, "Generated railpack-plan.json")
@@ -71,7 +71,7 @@ var InfoCommand = &cli.Command{
} else {
serializedResult, err := json.MarshalIndent(buildResult, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
fmt.Fprintln(&rendered, string(serializedResult))
}
@@ -83,19 +83,19 @@ var InfoCommand = &cli.Command{
return nil
} else {
if err := os.MkdirAll(filepath.Dir(output), 0755); err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
err = os.WriteFile(output, rendered.Bytes(), 0644)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
log.Infof("Plan written to %s", output)
}
if !buildResult.Success {
os.Exit(1)
os.Exit(ExitCodeFailure)
return nil
}
+5 -5
View File
@@ -26,17 +26,17 @@ var PlanCommand = &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
buildResult, _, _, err := GenerateBuildResultForCommand(cmd)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, exitCodeForError(err))
}
// Include $schema in the generated plan JSON for editor support
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
serializedPlan, err := json.MarshalIndent(planMap, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
buildResultString := serializedPlan
@@ -48,12 +48,12 @@ var PlanCommand = &cli.Command{
return nil
} else {
if err := os.MkdirAll(filepath.Dir(output), 0755); err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
err = os.WriteFile(output, []byte(buildResultString), 0644)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
log.Infof("Plan written to %s", output)
+27 -12
View File
@@ -38,7 +38,7 @@ var PrepareCommand = &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
buildResult, _, _, err := GenerateBuildResultForCommand(cmd)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, exitCodeForError(err))
}
// Pretty print the result to stdout unless hidden
@@ -47,7 +47,12 @@ var PrepareCommand = &cli.Command{
}
if !buildResult.Success {
os.Exit(1)
// still write the info file so callers can read the failure from it rather than stdout
if err := writeInfoFile(cmd, buildResult); err != nil {
return cli.Exit(err, ExitCodeFailure)
}
os.Exit(ExitCodeFailure)
return nil
}
@@ -55,11 +60,11 @@ var PrepareCommand = &cli.Command{
if cmd.Bool("show-plan") {
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
serialized, err := json.MarshalIndent(planMap, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
_, _ = os.Stdout.Write(serialized)
}
@@ -69,25 +74,35 @@ var PrepareCommand = &cli.Command{
// Include $schema in the plan JSON for editor support
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
if err := writeJSONFile(planOut, planMap, "Build plan written to %s"); err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
}
// Save info if requested
if infoOut := cmd.String("info-out"); infoOut != "" {
buildResult.Plan = nil
if err := writeJSONFile(infoOut, buildResult, "Build result info written to %s"); err != nil {
return cli.Exit(err, 1)
}
if err := writeInfoFile(cmd, buildResult); err != nil {
return cli.Exit(err, ExitCodeFailure)
}
return nil
},
}
// writes the build result to the --info-out file, if one was requested. The plan is
// dropped from the copy since it is written separately with --plan-out.
func writeInfoFile(cmd *cli.Command, buildResult *core.BuildResult) error {
infoOut := cmd.String("info-out")
if infoOut == "" {
return nil
}
info := *buildResult
info.Plan = nil
return writeJSONFile(infoOut, &info, "Build result info written to %s")
}
func writeJSONFile(path string, data any, logMessage string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
+1 -1
View File
@@ -19,7 +19,7 @@ var SchemaCommand = &cli.Command{
schemaJson, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return cli.Exit(err, 1)
return cli.Exit(err, ExitCodeFailure)
}
_, _ = os.Stdout.Write(schemaJson)
+28 -14
View File
@@ -13,6 +13,7 @@ import (
c "github.com/railwayapp/railpack/core/config"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/logger"
"github.com/railwayapp/railpack/core/mise"
"github.com/railwayapp/railpack/core/plan"
"github.com/railwayapp/railpack/core/providers"
"github.com/railwayapp/railpack/core/providers/procfile"
@@ -40,7 +41,8 @@ type BuildResult struct {
Metadata map[string]string `json:"metadata,omitempty"`
DetectedProviders []string `json:"detectedProviders,omitempty"`
Logs []logger.Msg `json:"logs,omitempty"`
Success bool `json:"success,omitempty"`
// always serialized so consumers of the info file can read the outcome of a failed build
Success bool `json:"success"`
}
func readConfigJSON(path string, v any) error {
@@ -63,19 +65,21 @@ func readConfigJSON(path string, v any) error {
return nil
}
func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) *BuildResult {
// generates a build plan for the app. The result is never nil: a failure to plan the
// app is reported with `Success: false` and the reason in `Logs`. A non-nil error is
// returned only for transient failures (see mise.IsTemporary), which say nothing about
// the app itself and are worth retrying.
func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) (*BuildResult, error) {
logger := logger.NewLogger()
config, err := GetConfig(app, env, options, logger)
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
return failedBuildResult(logger, err)
}
ctx, err := generate.NewGenerateContext(app, env, config, logger)
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
return failedBuildResult(logger, err)
}
// Set the previous versions
@@ -95,24 +99,21 @@ func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuil
if providerToUse != nil {
err = providerToUse.Plan(ctx)
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
return failedBuildResult(logger, err)
}
}
// Run the procfile provider to support apps that have a Procfile with a start command
procfileProvider := &procfile.ProcfileProvider{}
if _, err := procfileProvider.Plan(ctx); err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
return failedBuildResult(logger, err)
}
// before `Generate()` any commands provided by railpack.json are *not* merged into the provider-generated
// buildPlan. This means providers can't view any of the custom structure provided by the user via a railpack.json
buildPlan, resolvedPackages, err := ctx.Generate()
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
return failedBuildResult(logger, err)
}
railpackVersion := options.RailpackVersion
@@ -130,7 +131,7 @@ func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuil
ErrorMissingStartCommand: options.ErrorMissingStartCommand,
ProviderToUse: providerToUse,
}) {
return &BuildResult{Success: false, Logs: logger.Logs}
return &BuildResult{Success: false, Logs: logger.Logs}, nil
}
buildResult := &BuildResult{
@@ -143,7 +144,20 @@ func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuil
Success: true,
}
return buildResult
return buildResult, nil
}
// records a planning failure in the build result. Transient failures are also returned
// as an error so callers can tell them apart from a deterministic failure of the app.
func failedBuildResult(logger *logger.Logger, err error) (*BuildResult, error) {
logger.LogError("%s", err.Error())
result := &BuildResult{Success: false, Logs: logger.Logs}
if mise.IsTemporary(err) {
return result, err
}
return result, nil
}
// GetConfig merges the options, environment, and file config into a single config
+22 -2
View File
@@ -1,6 +1,8 @@
package core
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
@@ -8,6 +10,7 @@ import (
"github.com/gkampitakis/go-snaps/snaps"
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/logger"
"github.com/railwayapp/railpack/core/mise"
"github.com/stretchr/testify/require"
)
@@ -40,7 +43,8 @@ func TestGenerateBuildPlanForExamples(t *testing.T) {
require.NoError(t, err)
env := app.NewEnvironment(nil)
buildResult := GenerateBuildPlan(userApp, env, &GenerateBuildPlanOptions{})
buildResult, err := GenerateBuildPlan(userApp, env, &GenerateBuildPlanOptions{})
require.NoError(t, err)
if !buildResult.Success {
t.Fatalf("failed to generate build plan for %s: %s", entry.Name(), buildResult.Logs)
@@ -62,6 +66,21 @@ func TestGenerateBuildPlanForExamples(t *testing.T) {
}
}
func TestFailedBuildResult(t *testing.T) {
temporary := fmt.Errorf("failed to ensure mise is installed: %w",
&mise.TemporaryError{URL: "https://github.com/jdx/mise", Err: errors.New("i/o timeout")})
result, err := failedBuildResult(logger.NewLogger(), temporary)
require.ErrorIs(t, err, temporary, "transient failures must reach the caller so they can be retried")
require.False(t, result.Success)
require.NotEmpty(t, result.Logs)
result, err = failedBuildResult(logger.NewLogger(), errors.New("no start command was found"))
require.NoError(t, err, "deterministic failures are reported in the build result only")
require.False(t, result.Success)
require.NotEmpty(t, result.Logs)
}
func TestGenerateConfigFromFile_NotFound(t *testing.T) {
// Use an existing example app directory so relative paths resolve
appPath := "../examples/config-file"
@@ -128,7 +147,8 @@ func TestGenerateBuildPlan_DockerignoreMetadata(t *testing.T) {
require.NoError(t, err)
env := app.NewEnvironment(nil)
buildResult := GenerateBuildPlan(userApp, env, &GenerateBuildPlanOptions{})
buildResult, err := GenerateBuildPlan(userApp, env, &GenerateBuildPlanOptions{})
require.NoError(t, err)
require.True(t, buildResult.Success)
require.NotNil(t, buildResult.Metadata)
+30
View File
@@ -0,0 +1,30 @@
package mise
import (
"errors"
"fmt"
)
// TemporaryError marks a failure that is worth retrying: network transport
// errors, rate limits, and server-side errors. Deterministic failures (a 404
// for a version that does not exist, a malformed archive) are never wrapped in
// this, since retrying them cannot succeed.
type TemporaryError struct {
URL string
Err error
}
func (e *TemporaryError) Error() string {
return fmt.Sprintf("temporary failure fetching %s: %s", e.URL, e.Err)
}
func (e *TemporaryError) Unwrap() error {
return e.Err
}
// reports whether err (or anything it wraps) is a transient failure. Callers
// use this to decide whether retrying the whole operation makes sense.
func IsTemporary(err error) bool {
var temporary *TemporaryError
return errors.As(err, &temporary)
}
+53 -15
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"runtime"
"strings"
"time"
"github.com/charmbracelet/log"
)
@@ -20,7 +21,12 @@ import (
//go:embed version.txt
var Version string
const githubReleaseBase = "https://github.com/jdx/mise/releases/download"
// a var so tests can point the download at a local server
var githubReleaseBase = "https://github.com/jdx/mise/releases/download"
// http.DefaultClient has no timeout of any kind, so a stalled connection would
// hang the build indefinitely. The ceiling is generous to tolerate slow builders.
var downloadClient = &http.Client{Timeout: 5 * time.Minute}
// returns name of the mise binary based on the operating system
func getBinaryName() string {
@@ -105,11 +111,6 @@ func downloadAndInstall(cacheDir string) error {
log.Debugf("Downloading mise from %s", url)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download mise: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Create temporary directory
tempDir, err := os.MkdirTemp("", "mise-install")
if err != nil {
@@ -118,17 +119,10 @@ func downloadAndInstall(cacheDir string) error {
defer func() { _ = os.RemoveAll(tempDir) }()
archivePath := filepath.Join(tempDir, assetName)
f, err := os.Create(archivePath)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
if err := downloadArchive(url, archivePath); err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
return fmt.Errorf("failed to save archive: %w", err)
}
_ = f.Close()
if runtime.GOOS == "windows" {
err = extractZip(archivePath, binaryPath)
} else {
@@ -147,6 +141,50 @@ func downloadAndInstall(cacheDir string) error {
return nil
}
// downloads url to archivePath. Transient failures are typed so the caller can
// decide whether to retry; this deliberately does not retry on its own.
func downloadArchive(url, archivePath string) error {
resp, err := downloadClient.Get(url)
if err != nil {
// transport failures (dial timeouts, resets) are never the app's fault
return &TemporaryError{URL: url, Err: err}
}
defer func() { _ = resp.Body.Close() }()
if err := checkDownloadStatus(url, resp); err != nil {
return err
}
f, err := os.Create(archivePath)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, resp.Body); err != nil {
// the connection dropped mid-body, leaving a truncated archive
return &TemporaryError{URL: url, Err: fmt.Errorf("failed to save archive: %w", err)}
}
return nil
}
// classifies the response status. Rate limits and server errors are transient;
// anything else (notably a 404 for a mise version that does not exist) is not.
func checkDownloadStatus(url string, resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
statusErr := fmt.Errorf("unexpected status %s", resp.Status)
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return &TemporaryError{URL: url, Err: statusErr}
}
return fmt.Errorf("failed to download mise from %s: %w", url, statusErr)
}
func extractTarGz(archivePath, binaryPath string) error {
f, err := os.Open(archivePath)
if err != nil {
+100
View File
@@ -0,0 +1,100 @@
package mise
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
func TestDownloadArchiveClassifiesStatusCodes(t *testing.T) {
tests := []struct {
status int
isTemporary bool
}{
{status: http.StatusOK},
{status: http.StatusNotFound},
{status: http.StatusForbidden},
{status: http.StatusRequestTimeout, isTemporary: true},
{status: http.StatusTooManyRequests, isTemporary: true},
{status: http.StatusInternalServerError, isTemporary: true},
{status: http.StatusServiceUnavailable, isTemporary: true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("status-%d", tt.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
}))
defer server.Close()
err := downloadArchive(server.URL, filepath.Join(t.TempDir(), "mise.tar.gz"))
if tt.status == http.StatusOK {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Equal(t, tt.isTemporary, IsTemporary(err))
})
}
}
func TestDownloadArchiveWritesTheResponseBody(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte("archive contents"))
}))
defer server.Close()
archivePath := filepath.Join(t.TempDir(), "mise.tar.gz")
require.NoError(t, downloadArchive(server.URL, archivePath))
// the download is attempted once; retries are the caller's decision
require.EqualValues(t, 1, requests.Load())
contents, err := os.ReadFile(archivePath)
require.NoError(t, err)
require.Equal(t, "archive contents", string(contents))
}
func TestDownloadArchiveTransportFailureIsTemporary(t *testing.T) {
// closing the server up front guarantees a connection error rather than a response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := server.URL
server.Close()
err := downloadArchive(url, filepath.Join(t.TempDir(), "mise.tar.gz"))
require.True(t, IsTemporary(err))
}
// the install path wraps the download error several times before a caller sees it
func TestEnsureInstalledKeepsTemporaryFailuresDetectable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
originalBase := githubReleaseBase
githubReleaseBase = server.URL
t.Cleanup(func() { githubReleaseBase = originalBase })
_, err := ensureInstalled(filepath.Join(t.TempDir(), "mise"))
require.Error(t, err)
require.True(t, IsTemporary(err))
}
func TestIsTemporaryUnwrapsWrappedErrors(t *testing.T) {
temporary := &TemporaryError{URL: "https://example.com", Err: fmt.Errorf("i/o timeout")}
require.True(t, IsTemporary(fmt.Errorf("failed to ensure mise is installed: %w", temporary)))
require.False(t, IsTemporary(fmt.Errorf("binary not found in archive")))
require.False(t, IsTemporary(nil))
}
@@ -38,6 +38,22 @@ railpack prepare /dir/to/build \
![railpack prepare command](../images/railpack-prepare.png)
### Retrying a failed prepare
`prepare` distinguishes failures that are worth retrying from failures that are
not, so a platform does not have to retry (and re-log) an error the app will
hit again on every attempt.
- **Exit `75`** — a transient failure, such as a network error while
downloading Mise. Retry the command.
- **Exit `1`** — the app could not be planned (no provider detected, a missing
start command, an invalid config file). Fail the build on the first attempt.
- **No exit code** — the process never ran. Retry the command.
The build info file is written even when the build result is unsuccessful, so
`{"success": false, "logs": [...]}` can be read from `--info-out` rather than
scraped from stdout.
## Building with BuildKit
Each version of Railpack includes a BuildKit frontend available as an [image on
+17
View File
@@ -179,3 +179,20 @@ These options can be used with any command:
| `--help`, `-h` | Show help information |
| `--version`, `-v` | Show version information |
| `--verbose` | Enable verbose logging |
## Exit Codes
| Code | Meaning |
| ---- | ----------------------------------------------------------------------------- |
| `0` | Success |
| `1` | The command failed for a reason that will not change on a retry |
| `75` | The command failed for a transient reason and is worth retrying (EX_TEMPFAIL) |
Exit code `75` is only used for failures that say nothing about the app being
built, such as a network error while downloading Mise. Everything else — no
provider detected, a missing start command, an invalid config file — exits
with `1` and will fail the same way on every attempt.
Platforms that run Railpack in a build pipeline should retry on `75` and fail
the build on `1`. If the process exits without a code at all (the driver never
ran), that is also worth retrying.
+4 -1
View File
@@ -125,9 +125,12 @@ func TestExamplesIntegration(t *testing.T) {
}
env := app.NewEnvironment(&testCase.Envs)
buildResult := core.GenerateBuildPlan(userApp, env, &core.GenerateBuildPlanOptions{
buildResult, err := core.GenerateBuildPlan(userApp, env, &core.GenerateBuildPlanOptions{
ConfigFilePath: testCase.ConfigFilePath,
})
if err != nil {
t.Fatalf("failed to generate build plan: %v", err)
}
// Handle case where we expect the build to fail
if testCase.ShouldFail {