From 38a8d2b77e3da3f9fa1eb8c15e5900d03d6b6746 Mon Sep 17 00:00:00 2001 From: E Ismail Date: Mon, 5 Oct 2026 10:40:07 +0300 Subject: [PATCH] wire: site_withdrawn, the publish refusal for a site that has been withdrawn A deploy whose site has been withdrawn (it expired, or its owner or the service took it down) will never be published again, and no code in the contract said so. deploy_not_ready would tell a client to wait, and deploy_failed would tell a person their build was refused; neither is true. site_withdrawn is terminal, carries no Retry-After and is never a stream event, and it is appended at the end of the code list so nothing a released client can observe moves. The client stops on it at the publish step with its own failure, site-withdrawn: the site has been withdrawn, a withdrawn site is not published again, and a fresh deploy is the way forward, with the server's own message quoted. It exits 1, not the closed-door 3, and it does not say nothing was deployed, because the deploy may have been live before its site was withdrawn and this client cannot tell. Every routing table over the contract states a route for it; the create and login tables declare it as one of the publish step's codes, which cannot reach them. The failure id is registered, the catalog is regenerated and the README carries its entry. A golden fixture holds the envelope. --- README.md | 6 ++++ catalog.json | 14 ++++++++ internal/api/publish_test.go | 3 +- internal/flow/create.go | 18 ++++++---- internal/flow/login.go | 15 +++++---- internal/flow/publish.go | 19 +++++++++++ internal/flow/publish_test.go | 33 +++++++++++++++++++ internal/ui/failureid.go | 3 +- pkg/wire/contract_guard_test.go | 6 ++++ .../error_response_site_withdrawn.json | 6 ++++ pkg/wire/wire.go | 14 ++++++++ pkg/wire/wire_test.go | 13 ++++++++ 12 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 pkg/wire/testdata/error_response_site_withdrawn.json diff --git a/README.md b/README.md index d8f5c6e..e5ad599 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/catalog.json b/catalog.json index 937cdbf..ace568a 100644 --- a/catalog.json +++ b/catalog.json @@ -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" diff --git a/internal/api/publish_test.go b/internal/api/publish_test.go index 2aee7ef..b0aacae 100644 --- a/internal/api/publish_test.go +++ b/internal/api/publish_test.go @@ -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. @@ -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) { diff --git a/internal/flow/create.go b/internal/flow/create.go index 13ead05..718faaa 100644 --- a/internal/flow/create.go +++ b/internal/flow/create.go @@ -88,9 +88,9 @@ 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 @@ -98,10 +98,14 @@ var createRouting = map[wire.ErrorCode]createRoute{ 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 diff --git a/internal/flow/login.go b/internal/flow/login.go index bdcd128..abe3118 100644 --- a/internal/flow/login.go +++ b/internal/flow/login.go @@ -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 diff --git a/internal/flow/publish.go b/internal/flow/publish.go index 1210a84..69af2ba 100644 --- a/internal/flow/publish.go +++ b/internal/flow/publish.go @@ -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, @@ -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 diff --git a/internal/flow/publish_test.go b/internal/flow/publish_test.go index c415249..87854de 100644 --- a/internal/flow/publish_test.go +++ b/internal/flow/publish_test.go @@ -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. diff --git a/internal/ui/failureid.go b/internal/ui/failureid.go index 46ff531..1487119 100644 --- a/internal/ui/failureid.go +++ b/internal/ui/failureid.go @@ -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" @@ -171,7 +172,7 @@ var ActiveFailureIDs = []FailureID{ IDBuildFailedUnexplained, IDBuildLogLost, IDBuildOutputRefused, IDPublishNotConfirmed, IDPublishedAddressInvalid, IDDeployUnknownToServer, - IDDeployNotCompletedByServer, + IDDeployNotCompletedByServer, IDSiteWithdrawn, IDWaitlistDeclined, IDWaitlistNeedsTerminal, IDWaitlistSignupFailed, diff --git a/pkg/wire/contract_guard_test.go b/pkg/wire/contract_guard_test.go index 1a33895..9ffcda1 100644 --- a/pkg/wire/contract_guard_test.go +++ b/pkg/wire/contract_guard_test.go @@ -260,6 +260,7 @@ func TestEveryErrorCodeConstantIsPinned(t *testing.T) { "CodeBuildFailed": "build_failed", "CodeLimitReached": "limit_reached", "CodeStoreFull": "store_full", + "CodeSiteWithdrawn": "site_withdrawn", } declared := declaredErrorCodeValues(t) @@ -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{} @@ -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] diff --git a/pkg/wire/testdata/error_response_site_withdrawn.json b/pkg/wire/testdata/error_response_site_withdrawn.json new file mode 100644 index 0000000..ddbfad4 --- /dev/null +++ b/pkg/wire/testdata/error_response_site_withdrawn.json @@ -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." + } +} diff --git a/pkg/wire/wire.go b/pkg/wire/wire.go index d60089a..c3354c1 100644 --- a/pkg/wire/wire.go +++ b/pkg/wire/wire.go @@ -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 @@ -226,6 +239,7 @@ var AllErrorCodes = []ErrorCode{ CodeBuildFailed, CodeLimitReached, CodeStoreFull, + CodeSiteWithdrawn, } // retryAfterCodes is the set behind CarriesRetryAfter. It is unexported diff --git a/pkg/wire/wire_test.go b/pkg/wire/wire_test.go index 613e798..fc15ba6 100644 --- a/pkg/wire/wire_test.go +++ b/pkg/wire/wire_test.go @@ -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 @@ -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) @@ -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) {