mirror of
https://github.com/hanzoai/pack.git
synced 2026-09-30 23:43:13 +00:00
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:
+6
-6
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 \
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user