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
5 changes: 5 additions & 0 deletions .changeset/rule-service-failure-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

Rule service failures now say what went wrong and whether to retry. A network failure names its cause (for example `getaddrinfo ENOTFOUND`) instead of `fetch failed`, and a network failure, `408`, `429`, or `5xx` adds advice to try again. `check` no longer describes a request the service rejected, such as `validation_error`, as the service being unavailable. `rule restore`, `rollback`, and `revisions` report `validation_error` as `INVALID_INPUT` with the service's details, where they previously reported `NETWORK_ERROR`. Every plan refusal now shows the upgrade link, including on `rule create`, `rule improve`, and fetching a generated rule.
15 changes: 15 additions & 0 deletions packages/cli/src/api/refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,18 @@ export function parseRefusal(value: unknown): Refusal | undefined {
...(upgradeUrl === undefined ? {} : { upgradeUrl }),
};
}

/**
* The text to print for a refusal: the service's message, followed by the
* upgrade link when there is one.
*
* The service writes the link into `message` itself (measured against
* production, 2026-09-29), so it is added only when absent. Printing it twice
* reads as two different links, and relying on the service to keep writing it
* would drop it silently the day it stops.
*/
export function describeRefusal({ message, upgradeUrl }: Refusal): string {
return upgradeUrl === undefined || message.includes(upgradeUrl)
? message
: `${message}\n\nUpgrade: ${upgradeUrl}`;
}
75 changes: 66 additions & 9 deletions packages/cli/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import createClient from "openapi-fetch";

import type { paths } from "../generated/api-v2";
import { getApiBaseUrl } from "./config";
import { parseRefusal, type Refusal } from "./refusal";
import { parseRefusal, stripControlCharacters, type Refusal } from "./refusal";
import { isRecord } from "../util/is-record";
import { CLI_VERSION, CLI_VERSION_HEADER } from "../version";

Expand Down Expand Up @@ -60,13 +60,27 @@ export type ErrorCode<P extends keyof paths, M extends Method> = Exclude<
* same way (log in again) and it is never a verdict about the request.
* - `unavailable`: anything the schema does not describe: a network failure, an
* undocumented status or code, or a body that is not what it says it is.
* `retryable` marks the ones a later attempt may not repeat (a network
* failure, `408`, `429`, or a `5xx`); a malformed body or an undocumented
* `4xx` will be answered the same way next time.
*/
export type V2Outcome<T, C extends string> =
| { status: "ok"; data: T }
| { status: "refused"; refusal: Refusal }
| { status: "error"; code: C; httpStatus: number; details?: string[] }
| { status: "unauthorized" }
| { status: "unavailable"; reason: string };
| { status: "unavailable"; reason: string; retryable: boolean };

/**
* The sentence to append to a message about an `unavailable` outcome: advice
* to try again when the failure is one a later attempt may not repeat, and
* nothing otherwise. Retrying a malformed answer gets the same answer.
*/
export function retryAdvice(outcome: { retryable: boolean }): string {
return outcome.retryable
? " This is usually temporary; try again in a few minutes."
: "";
}

/**
* A list of an operation's error codes, checked for completeness at compile
Expand Down Expand Up @@ -96,6 +110,28 @@ export function createV2Client(token: string) {

type Fetched = { data?: unknown; error?: unknown; response: Response };

/** Whether an undocumented status is one a later attempt may not repeat. */
function isTransientStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}

/**
* Describe a thrown fetch by its cause.
*
* Node's fetch throws `TypeError("fetch failed")` for every transport failure
* and puts what actually happened (DNS, a refused connection, TLS) on `cause`.
* The outer message says nothing a user can act on, so the cause replaces it
* when there is one. A refused connection to a dual-stack host is an
* `AggregateError` with an empty message, which leaves only its `code`.
*/
function describeNetworkError(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const { cause } = error;
if (cause instanceof Error && cause.message !== "") return cause.message;
if (isRecord(cause) && typeof cause.code === "string") return cause.code;
return error.message;
}

/**
* Turn an `openapi-fetch` result into an outcome.
*
Expand All @@ -112,22 +148,36 @@ async function settle<T, C extends string>(
try {
fetched = await call();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { status: "unavailable", reason: `network error: ${message}` };
// A body that failed to parse is an answer, not a transport failure, and
// the same server will most likely give it again.
return error instanceof SyntaxError
? {
status: "unavailable",
reason: "invalid response body",
retryable: false,
}
: {
status: "unavailable",
reason: `network error: ${describeNetworkError(error)}`,
retryable: true,
};
}

const { response } = fetched;
if (response.status === 401) return { status: "unauthorized" };
if (response.ok) return accept(fetched.data);

// `details` and an undocumented `code` are server text that ends up printed
// to a terminal, so both lose their control characters here, once, rather
// than at every message that interpolates them.
const body = fetched.error;
const code = isRecord(body) ? body.error : undefined;
if (typeof code === "string" && (codes as readonly string[]).includes(code)) {
const details =
isRecord(body) && Array.isArray(body.details)
? body.details.filter(
(detail): detail is string => typeof detail === "string"
)
? body.details
.filter((detail): detail is string => typeof detail === "string")
.map((detail) => stripControlCharacters(detail))
: undefined;
return {
status: "error",
Expand All @@ -140,15 +190,20 @@ async function settle<T, C extends string>(
status: "unavailable",
reason:
typeof code === "string"
? `HTTP ${String(response.status)} (${code})`
? `HTTP ${String(response.status)} (${stripControlCharacters(code)})`
: `HTTP ${String(response.status)}`,
retryable: isTransientStatus(response.status),
};
}

/** Accept any object body as the documented shape. */
function acceptObject<T, C extends string>(data: unknown): V2Outcome<T, C> {
if (!isRecord(data)) {
return { status: "unavailable", reason: "invalid response body" };
return {
status: "unavailable",
reason: "invalid response body",
retryable: false,
};
}
return { status: "ok", data: data as T };
}
Expand Down Expand Up @@ -187,6 +242,7 @@ function acceptServed<C extends string>(
return {
status: "unavailable",
reason: "the response carried neither a rule nor a refusal",
retryable: false,
};
}
const { restoreRules: _marker, ...served } = data;
Expand Down Expand Up @@ -333,6 +389,7 @@ function acceptRevisions<C extends string>(
return {
status: "unavailable",
reason: "the response was not a revision listing",
retryable: false,
};
}
return { status: "ok", data: data as unknown as RevisionList };
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import {
resolveIdentity,
type Identity,
} from "../auth/identity";
import { iterateRule, submitRequest, type V2Outcome } from "../api/v2";
import { describeRefusal } from "../api/refusal";
import {
iterateRule,
retryAdvice,
submitRequest,
type V2Outcome,
} from "../api/v2";
import { readRuleMetaFile, deleteRuleFiles } from "../rules/files";
import {
awaitRequest,
Expand Down Expand Up @@ -58,12 +64,15 @@ function describeSubmitFailure(
}
case "unavailable": {
return {
message: `Request submission failed: ${outcome.reason}.`,
message: `Request submission failed: ${outcome.reason}.${retryAdvice(outcome)}`,
code: "NETWORK_ERROR",
};
}
case "refused": {
return { message: outcome.refusal.message, code: "NETWORK_ERROR" };
return {
message: describeRefusal(outcome.refusal),
code: "NETWORK_ERROR",
};
}
case "error": {
switch (outcome.code) {
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/rules/generate.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
fetchRule,
getRequestStatus,
retryAdvice,
type RequestStatus,
type ServedRule,
} from "../api/v2";
import { notRunOnPlanSentence, parseEntitlementV2 } from "../api/entitlement";
import { stripControlCharacters } from "../api/refusal";
import { describeRefusal, stripControlCharacters } from "../api/refusal";
import { CLIError } from "../util/cli-error";
import { getCliPrefix } from "../util/package-manager";
import { writeServedRule } from "./files";
Expand Down Expand Up @@ -99,10 +100,15 @@ export async function awaitRequest(
"NETWORK_ERROR"
);
}
case "refused":
case "refused": {
throw new CLIError(
"Polling failed: unexpected refusal.",
"NETWORK_ERROR"
);
}
case "unavailable": {
throw new CLIError(
`Polling failed: ${outcome.status === "unavailable" ? outcome.reason : "unexpected refusal"}.`,
`Polling failed: ${outcome.reason}.${retryAdvice(outcome)}`,
"NETWORK_ERROR"
);
}
Expand Down Expand Up @@ -227,7 +233,7 @@ function servedOrThrow(
// a revision the CLI did not ask for. Relay its message; it is the only
// explanation available.
throw new CLIError(
`Rule ${ruleId} was generated but could not be fetched: ${outcome.refusal.message}`,
`Rule ${ruleId} was generated but could not be fetched: ${describeRefusal(outcome.refusal)}`,
"RULE_GENERATION_FAILED"
);
}
Expand All @@ -245,7 +251,7 @@ function servedOrThrow(
}
case "unavailable": {
throw new CLIError(
`Rule ${ruleId} could not be fetched: ${outcome.reason}.`,
`Rule ${ruleId} could not be fetched: ${outcome.reason}.${retryAdvice(outcome)}`,
"NETWORK_ERROR"
);
}
Expand Down
52 changes: 37 additions & 15 deletions packages/cli/src/rules/plan-check.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getToken } from "../auth/token";
import { resolveActingOrg } from "../auth/org";
import { reconcileRules } from "../api/v2";
import { reconcileRules, retryAdvice } from "../api/v2";
import { resolveRepositoryUrl } from "../util/git-remote";
import { getCliPrefix } from "../util/package-manager";
import { recoveryAdvice } from "./recovery-advice";
Expand Down Expand Up @@ -212,25 +212,15 @@ export async function planCheck(
}`
);
if (outcome.status !== "ok") {
const cause =
outcome.status === "unauthorized"
? `authentication was rejected — run \`${getCliPrefix()} auth login\` to re-authenticate`
: outcome.status === "error" &&
outcome.code === "organization_not_found"
? "the Taskless GitHub App installation does not cover this repository, or your login lost access to the organization"
: `the rule service was unavailable (${
outcome.status === "unavailable"
? outcome.reason
: outcome.status === "error"
? outcome.code
: "unexpected refusal"
})`;
const cause = reconcileFailureCause(outcome);
const remaining = await discoverRuntimeRulesIn(runtimeRoot);
return {
...empty,
skipped: remaining.map((rule) => ({ rule: rule.name, reason: cause })),
notices: [
`Rule verification could not be performed: ${cause}. Static rules ran unverified and runtime rules did not run.`,
`Rule verification could not be performed: ${cause}. Static rules ran unverified and runtime rules did not run.${
outcome.status === "unavailable" ? retryAdvice(outcome) : ""
}`,
],
failures,
integrity,
Expand Down Expand Up @@ -341,3 +331,35 @@ function withheldNotice(entitlement: PlanEntitlement): string {
: `. Upgrade at ${entitlement.upgradeUrl}`)
);
}

/**
* Why reconcile gave no verdicts, as a clause for the notice and each skipped
* rule.
*
* "Unavailable" is kept for the service not answering. A documented code is an
* answer, and calling it an outage sends the user to retry something that
* will be rejected identically every time.
*/
function reconcileFailureCause(
outcome: Exclude<Awaited<ReturnType<typeof reconcileRules>>, { status: "ok" }>
): string {
switch (outcome.status) {
case "unauthorized": {
return `authentication was rejected — run \`${getCliPrefix()} auth login\` to re-authenticate`;
}
case "unavailable": {
return `the rule service was unavailable (${outcome.reason})`;
}
case "refused": {
return "the rule service answered with an unexpected refusal";
}
case "error": {
if (outcome.code === "organization_not_found") {
return "the Taskless GitHub App installation does not cover this repository, or your login lost access to the organization";
}
return `the rule service rejected the verification request (${outcome.code}${
Comment thread
theCodeDrift marked this conversation as resolved.
outcome.details?.length ? `: ${outcome.details.join(", ")}` : ""
})`;
}
}
}
22 changes: 13 additions & 9 deletions packages/cli/src/rules/recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {
listRevisions,
reconcileRules,
restoreRule,
retryAdvice,
rollbackRule,
type RevisionList,
type ServedRule,
type V2Outcome,
} from "../api/v2";
import { notRunOnPlanSentence, parseEntitlementV2 } from "../api/entitlement";
import { describeRefusal } from "../api/refusal";
import type { Identity } from "../auth/identity";
import { CLIError } from "../util/cli-error";
import { isRecord } from "../util/is-record";
Expand Down Expand Up @@ -73,14 +75,8 @@ function failure(
): CLIError {
switch (outcome.status) {
case "refused": {
const { message, upgradeUrl } = outcome.refusal;
// The service writes the upgrade link into `message` itself (measured
// against production, 2026-09-29), so it is added only when absent.
// Printing it twice reads as two different links.
return new CLIError(
upgradeUrl === undefined || message.includes(upgradeUrl)
? message
: `${message}\n\nUpgrade: ${upgradeUrl}`,
describeRefusal(outcome.refusal),
"RULE_RECOVERY_NOT_IN_PLAN"
);
}
Expand All @@ -92,7 +88,7 @@ function failure(
}
case "unavailable": {
return new CLIError(
`The rule service was unavailable (${outcome.reason}).`,
`The rule service was unavailable (${outcome.reason}).${retryAdvice(outcome)}`,
"NETWORK_ERROR"
);
}
Expand Down Expand Up @@ -120,9 +116,17 @@ function failure(
"NETWORK_ERROR"
);
}
case "validation_error": {
return new CLIError(
`The rule service rejected the request as invalid: ${(outcome.details ?? []).join(", ") || "no details were given"}.`,
Comment thread
theCodeDrift marked this conversation as resolved.
"INVALID_INPUT"
);
}
default: {
return new CLIError(
`The rule service refused the request (${outcome.code}).`,
`The rule service refused the request (${outcome.code}${
outcome.details?.length ? `: ${outcome.details.join(", ")}` : ""
}).`,
"NETWORK_ERROR"
);
}
Expand Down
Loading
Loading