Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,12 @@ inside it.

> curious.pub isn't taking this right now.

### site-withdrawn

**addresses.** A person is waiting for a finished deploy to be given its public address.

> This deploy's site has been withdrawn.

### temp-dir-unusable

**this machine.** A person is getting this machine ready before anything is sent: the project directory, where the login is kept, the API address or the temporary directory.
Expand Down
14 changes: 14 additions & 0 deletions catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,20 @@
"this request"
]
},
{
"action": [
"FreshDeploy"
],
"family": null,
"headline": {
"addresses": "This deploy's site has been withdrawn."
},
"headline_reason": {},
"id": "site-withdrawn",
"stages": [
"addresses"
]
},
{
"action": [
"FreshDeploy"
Expand Down
3 changes: 2 additions & 1 deletion internal/api/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func TestDeployPublish_IsNeverRetried(t *testing.T) {
}
}

// TestDeployPublish_CarriesTheServersCodeThrough. The two codes this
// TestDeployPublish_CarriesTheServersCodeThrough. The codes this
// endpoint answers with are what a client switches on, so an *APIError
// that lost one would send the caller back to reading prose — which is
// the thing these codes exist to stop.
Expand All @@ -131,6 +131,7 @@ func TestDeployPublish_CarriesTheServersCodeThrough(t *testing.T) {
}{
{"the build is finished with", wire.CodeDeployFailed},
{"the build has not finished", wire.CodeDeployNotReady},
{"the site was withdrawn", wire.CodeSiteWithdrawn},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
18 changes: 11 additions & 7 deletions internal/flow/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,24 @@ var createRouting = map[wire.ErrorCode]createRoute{
wire.CodeForbidden: createStop,
wire.CodeNotFound: createStop,
wire.CodeInternal: createStop,
// THE PUBLISH STEP'S TWO CODES, declared here and not reachable from
// this call — the same shape the closed-capacity code already has.
// The create cannot receive them: they say a deploy is not in a
// TWO OF THE PUBLISH STEP'S CODES, declared here and not reachable
// from this call — the same shape the closed-capacity code already
// has. The create cannot receive them: they say a deploy is not in a
// publishable state, and this call is what brings a deploy into
// existence. Stated anyway, because the contract's list is what this
// table is keyed to, and a code with no entry would be answered by a
// fallback nobody chose.
wire.CodeDeployFailed: createStop,
wire.CodeDeployNotReady: createStop,
// Not reachable from this call either: two travel only on a build's
// event stream, and the third is the publish step's full store.
wire.CodeBuildFailed: createStop,
wire.CodeLimitReached: createStop,
wire.CodeStoreFull: createStop,
// event stream, and the other two are the publish step's — its full
// store, and a site that has been withdrawn, which only a publish can
// be told about because only a publish names a deploy whose site
// could have gone.
wire.CodeBuildFailed: createStop,
wire.CodeLimitReached: createStop,
wire.CodeStoreFull: createStop,
wire.CodeSiteWithdrawn: createStop,
}

// deployCreator is the slice of the API client this step needs. It is
Expand Down
15 changes: 8 additions & 7 deletions internal/flow/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,14 @@ var errorRouting = map[wire.ErrorCode]verifyRoute{
wire.CodeCapacityClosed: routeStop,

// NOT REACHABLE FROM A LOGIN. Two say how a build stopped and travel
// only on the build's event stream; the third is the publish step's
// full store. Stated because this table is keyed to the contract's
// list, and a code with no entry would be answered by a fallback
// nobody chose.
wire.CodeBuildFailed: routeStop,
wire.CodeLimitReached: routeStop,
wire.CodeStoreFull: routeStop,
// only on the build's event stream; the other two are the publish
// step's — its full store, and a withdrawn site. Stated because this
// table is keyed to the contract's list, and a code with no entry
// would be answered by a fallback nobody chose.
wire.CodeBuildFailed: routeStop,
wire.CodeLimitReached: routeStop,
wire.CodeStoreFull: routeStop,
wire.CodeSiteWithdrawn: routeStop,

// The kill switch. Worth retrying later, and carrying no reset the
// server can honestly name — which is exactly why the contract's own
Expand Down
19 changes: 19 additions & 0 deletions internal/flow/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ var publishRouting = map[wire.ErrorCode]publishRoute{
// fix, and trying again will not help until it has.
wire.CodeStoreFull: publishStop,

// The site this deploy belongs to has been withdrawn, and will not be
// published again. Terminal: asking again cannot bring it back, so
// this stops rather than inviting a retry.
wire.CodeSiteWithdrawn: publishStop,

// Not reachable from this call: both travel only on a build's event
// stream.
wire.CodeBuildFailed: publishStop,
Expand Down Expand Up @@ -563,6 +568,20 @@ func publishStopFailure(apiErr *api.APIError, deployID string, now time.Time) er
"nothing to fix at this end and nothing here worth retrying. If it keeps\n"+
"happening, please get in touch.").Quoting(apiErr.Message)

case wire.CodeSiteWithdrawn:
// NO "NOTHING WAS DEPLOYED" LINE, and no retry advice. The deploy
// may have been live before its site was withdrawn, and this
// client cannot know which, so it does not claim either. Publishing
// again will not bring the site back; a fresh deploy is the way
// forward. It is a failure of this run, so it costs 1, and it is
// not the closed door.
return ui.NewFailure(
ui.IDSiteWithdrawn,
ui.StageAddresses,
"This deploy's site has been withdrawn.",
ui.Written("A withdrawn site is not published again.\n\nThe deploy is %s.", deployID), ui.NextFreshDeploy,
"Run `curious deploy` again to make a fresh one.").Quoting(apiErr.Message)

case wire.CodeInternal:
// The server failed, and its own message says to try again, so
// this copy says the same. The full-store case that used to share
Expand Down
33 changes: 33 additions & 0 deletions internal/flow/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,39 @@ func TestEachRefusalHasItsOwnCopyAndItsOwnCost(t *testing.T) {
// contradicting the sentence above it.
neverSay: []string{"Try again"},
},
{
// A withdrawn site is refused without the "nothing was
// deployed" line, and that is deliberate: the deploy may have
// been live before the site was withdrawn, and this client
// cannot know which. Saying nothing was deployed would be
// asserting a thing it has no way to check. The cost is 1, a
// failure of this run, and not the closed-door cost: the
// capacity and maintenance rows in this same table exit 3
// through the same harness, which is what makes the 1 here a
// measurement and not a default.
//
// REQUIRED MUTATION, run 2026-10-05: each of these was made
// to this code's copy alone and this row run, and each reds.
// Wrapping the failure in the closed-door constructor reds on
// "exit code = 3, want 1". Putting the nothing-was-deployed
// line into the body reds on neverSay, which names it.
// Replacing the fresh-deploy
// text with "Try again in a moment." reds on says (the fresh
// deploy line never appeared) and on neverSay. Dropping the
// quoted server message reds on says. Separately, changing the
// next action to a wait leaves THIS row green, because the
// terminal output does not print the action; the generated
// catalog is what reds for that, and the row does not claim it.
name: "the site was withdrawn",
outcome: fails(http.StatusGone, wire.CodeSiteWithdrawn,
"This site has been withdrawn and will not be published again."),
wantCode: 1,
says: []string{"This deploy's site has been withdrawn.",
"A withdrawn site is not published again.", "deploy-1",
"This site has been withdrawn and will not be published again.",
"Run `curious deploy` again to make a fresh one."},
neverSay: []string{"Try again", nothingDeployed},
},
{
// REQUIRED MUTATION: restore "nothing here worth retrying" on
// this code, and this row reds on its neverSay.
Expand Down
3 changes: 2 additions & 1 deletion internal/ui/failureid.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const (
IDPublishedAddressInvalid FailureID = "published-address-invalid"
IDDeployUnknownToServer FailureID = "deploy-unknown-to-server"
IDDeployNotCompletedByServer FailureID = "deploy-not-completed-by-server"
IDSiteWithdrawn FailureID = "site-withdrawn"

// The waitlist, when the door is closed.
IDWaitlistDeclined FailureID = "waitlist-declined"
Expand Down Expand Up @@ -171,7 +172,7 @@ var ActiveFailureIDs = []FailureID{
IDBuildFailedUnexplained,
IDBuildLogLost, IDBuildOutputRefused, IDPublishNotConfirmed,
IDPublishedAddressInvalid, IDDeployUnknownToServer,
IDDeployNotCompletedByServer,
IDDeployNotCompletedByServer, IDSiteWithdrawn,

IDWaitlistDeclined, IDWaitlistNeedsTerminal, IDWaitlistSignupFailed,

Expand Down
6 changes: 6 additions & 0 deletions pkg/wire/contract_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ func TestEveryErrorCodeConstantIsPinned(t *testing.T) {
"CodeBuildFailed": "build_failed",
"CodeLimitReached": "limit_reached",
"CodeStoreFull": "store_full",
"CodeSiteWithdrawn": "site_withdrawn",
}

declared := declaredErrorCodeValues(t)
Expand Down Expand Up @@ -620,6 +621,9 @@ func TestCarriesRetryAfterIsPinned(t *testing.T) {
// A full store reopens when someone empties it, at no time the
// server can name.
CodeStoreFull: false,
// A withdrawn site is never coming back, so there is no time to
// wait for.
CodeSiteWithdrawn: false,
}

listed := map[ErrorCode]bool{}
Expand Down Expand Up @@ -1172,6 +1176,8 @@ func TestStreamOnlyIsPinned(t *testing.T) {
CodeBuildFailed: true,
CodeLimitReached: true,
CodeStoreFull: false,
// An HTTP refusal of the publish call, never a stream event.
CodeSiteWithdrawn: false,
}
for _, code := range AllErrorCodes {
want, stated := pinned[code]
Expand Down
6 changes: 6 additions & 0 deletions pkg/wire/testdata/error_response_site_withdrawn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"error": {
"code": "site_withdrawn",
"message": "This site has been withdrawn and will not be published again. Deploy again to publish a fresh one."
}
}
14 changes: 14 additions & 0 deletions pkg/wire/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ const (
// code carried opposite advice. A client could only tell them apart by
// reading the message, which this contract refuses to make anyone do.
CodeStoreFull ErrorCode = "store_full"

// CodeSiteWithdrawn means the site this deploy belongs to has been
// withdrawn — it expired, or its owner or the service took it down —
// and it will not be published again. It is terminal: publishing
// again will not bring it back, so a client should not advise a
// retry; a fresh deploy is the way forward. It carries no
// Retry-After, and it is an HTTP refusal, never a stream event.
//
// It is a code of its own because CodeDeployNotReady and
// CodeDeployFailed would each say something false: one would tell a
// client to wait, the other would tell a person their build was
// refused, and neither is true of a withdrawn site.
CodeSiteWithdrawn ErrorCode = "site_withdrawn"
)

// AllErrorCodes is every ErrorCode this contract defines, in declaration
Expand Down Expand Up @@ -226,6 +239,7 @@ var AllErrorCodes = []ErrorCode{
CodeBuildFailed,
CodeLimitReached,
CodeStoreFull,
CodeSiteWithdrawn,
}

// retryAfterCodes is the set behind CarriesRetryAfter. It is unexported
Expand Down
13 changes: 13 additions & 0 deletions pkg/wire/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ func goldenCases() []goldenCase {
},
newEmpty: func() any { return &ErrorResponse{} },
},
{
name: "ErrorResponseSiteWithdrawn",
fixture: "error_response_site_withdrawn.json",
value: &ErrorResponse{
Error: Error{
Code: CodeSiteWithdrawn,
Message: "This site has been withdrawn and will not be published again. Deploy again to publish a fresh one.",
},
},
newEmpty: func() any { return &ErrorResponse{} },
},
{
// The deploy event stream's `error` event is a bare wire.Error
// (see DoneEvent's doc), so a stream-only code is pinned in the
Expand Down Expand Up @@ -397,6 +408,7 @@ func TestAllErrorCodesOrder(t *testing.T) {
"rate_limited", "capacity_closed", "maintenance", "internal",
"deploy_failed", "deploy_not_ready",
"build_failed", "limit_reached", "store_full",
"site_withdrawn",
}
if !reflect.DeepEqual(AllErrorCodes, want) {
t.Fatalf("AllErrorCodes = %v, want %v — declaration order, as its doc states", AllErrorCodes, want)
Expand Down Expand Up @@ -425,6 +437,7 @@ func TestErrorCodeConstants(t *testing.T) {
{CodeBuildFailed, "build_failed"},
{CodeLimitReached, "limit_reached"},
{CodeStoreFull, "store_full"},
{CodeSiteWithdrawn, "site_withdrawn"},
}

if len(cases) != len(AllErrorCodes) {
Expand Down
Loading