-
Notifications
You must be signed in to change notification settings - Fork 1
fix: name the host, the reason, and the setting when a request fails #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,11 +5,30 @@ import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-pr | |
| import { die } from './util.js' | ||
| import { USER_AGENT } from './version.js' | ||
| import { agentHeaders, agentMode } from './agent.js' | ||
| import { describeTarget, failureReason, hostOf, targetLines } from './target.js' | ||
|
|
||
| /** Extra lines an error carries under its first line: which host answered (or did not), and what | ||
| * pointed the CLI there. Attached at throw time, where the target is known; index.ts prints them. */ | ||
| export type ErrorContext = string[] | ||
|
|
||
| export class ApiError extends Error { | ||
| // body carries the parsed error payload for callers that branch on machine-readable errors | ||
| // (e.g. template deploy's missing_variables); the message stays the human line. | ||
| constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' } | ||
| constructor(public status: number, msg: string, public body?: any, public context?: ErrorContext) { super(msg); this.name = 'ApiError' } | ||
| } | ||
|
|
||
| /** The request never got an answer: DNS, TCP, TLS, or a timeout. | ||
| * | ||
| * undici collapses all of those into `TypeError: fetch failed`, which the CLI printed verbatim — | ||
| * no host, no reason, no hint that the target was not the cloud. This names the host it tried and | ||
| * why it failed, and keeps the original as `cause` so telemetry still lifts `cause.code`. */ | ||
| export class NetworkError extends Error { | ||
| constructor(public url: string, cause: unknown, public context?: ErrorContext) { | ||
| const reason = failureReason(cause) | ||
| super(`cannot reach ${hostOf(url)}${reason ? ` (${reason})` : ''}`) | ||
| this.name = 'NetworkError' | ||
| this.cause = cause | ||
| } | ||
| } | ||
| export class AgentApprovalRequired extends Error { | ||
| constructor(public body: any) { super(body.message ?? `approval required: ${body.approvalId}`) } | ||
|
|
@@ -57,17 +76,27 @@ export class ApiClient { | |
| async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> { | ||
| const res = await this.raw(method, path, body, opts.auth ?? true) | ||
| if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body) | ||
| if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) | ||
| if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext()) | ||
| return res.body as T | ||
| } | ||
|
|
||
| // Like request but returns {status, body} so callers can branch on 202 (approval_required). | ||
| async rawRequest(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<RawResult> { | ||
| const res = await this.raw(method, path, body, opts.auth ?? true) | ||
| if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) | ||
| if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext()) | ||
| return res | ||
| } | ||
|
|
||
| // Computed once, and only on the failure path: a run that succeeds pays nothing, and the config | ||
| // file is not re-read per error. Empty on the cloud default, so nothing is added to the common case. | ||
| private noteCache?: ErrorContext | ||
| private async targetContext(): Promise<ErrorContext> { | ||
| if (!this.noteCache) { | ||
| try { this.noteCache = targetLines(await describeTarget(this.apiUrl)) } catch { this.noteCache = [] } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents |
||
| } | ||
| return this.noteCache | ||
| } | ||
|
|
||
| private async raw(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> { | ||
| let r = await this.fetch(method, path, body, auth) | ||
| if (r.status === 401 && auth && this.cfg.refreshToken) { | ||
|
|
@@ -80,11 +109,18 @@ export class ApiClient { | |
| const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT } | ||
| if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}` | ||
| if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body))) | ||
| const res = await this.fetchImpl(this.apiUrl + path, { | ||
| method, | ||
| headers, | ||
| body: body === undefined ? undefined : JSON.stringify(body), | ||
| }) | ||
| let res: Response | ||
| try { | ||
| res = await this.fetchImpl(this.apiUrl + path, { | ||
| method, | ||
| headers, | ||
| body: body === undefined ? undefined : JSON.stringify(body), | ||
| }) | ||
| } catch (e) { | ||
| // A transport failure, not an HTTP status: there is no response to parse and no 401 to | ||
| // refresh past, so it goes straight out as a NetworkError naming the host and the setting. | ||
| throw new NetworkError(this.apiUrl, (e as { cause?: unknown })?.cause ?? e, await this.targetContext()) | ||
| } | ||
| const text = await res.text() | ||
| let parsed: any = null | ||
| try { parsed = text ? JSON.parse(text) : null } catch { parsed = { raw: text } } | ||
|
Comment on lines
+112
to
126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the peer closes the connection while Prompt for AI agents |
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,17 +7,27 @@ | |||||||||
| // same file `login --api-url` already writes, so this adds a surface, not a concept. | ||||||||||
| import { readPersistedGlobal, resolveEnv, writeGlobal, type GlobalConfig } from '../config.js' | ||||||||||
| import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, normalizeUrl, type EnvName } from '../env.js' | ||||||||||
| import { describeTarget } from '../target.js' | ||||||||||
| import { die, info, printJson } from '../util.js' | ||||||||||
|
|
||||||||||
| export async function envShow(opts: { json?: boolean }): Promise<void> { | ||||||||||
| const { apiUrl, env, mcpUrl, skills } = await resolveEnv() | ||||||||||
| const mcpServer = mcpServerName(env ?? DEFAULT_ENV) | ||||||||||
| if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer, skills }) | ||||||||||
| info(`env: ${env ?? '(custom)'}`) | ||||||||||
| // `source` answers the question the old output left open: the URL was on screen, but nothing | ||||||||||
| // said which of INSTA_API_URL / INSTA_ENV / a months-old `login --api-url` had chosen it. | ||||||||||
| const target = await describeTarget(apiUrl) | ||||||||||
| // json gets the stable token, a terminal gets the prose. An agent that branched on the wording of | ||||||||||
| // "saved by `insta login --api-url`" would break the next time that sentence is reworded, and the | ||||||||||
| // backticks in it are noise in a machine field. | ||||||||||
| if (opts.json) return printJson({ env, apiUrl, source: target.kind, mcpUrl, mcpServer, skills }) | ||||||||||
| info(`env: ${env ?? 'custom'}`) | ||||||||||
| info(`api: ${apiUrl}`) | ||||||||||
| info(`mcp: ${mcpUrl} (${mcpServer})`) | ||||||||||
| info(`skills: ${skills}`) | ||||||||||
| if (!env) info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)') | ||||||||||
| info(`source: ${target.source}`) | ||||||||||
| // A custom apiUrl has no matching mcp/skills entry, so resolveEnv falls back to the cloud's. | ||||||||||
| // Say so: an unlabelled cloud mcp host under an insta-oss api host reads as a matched pair. | ||||||||||
| info(`mcp: ${mcpUrl} (${mcpServer}${env ? '' : ', cloud fallback'})`) | ||||||||||
| info(`skills: ${skills}${env ? '' : ' (cloud fallback)'}`) | ||||||||||
|
Comment on lines
+28
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a custom Prompt for AI agents
Suggested change
|
||||||||||
| if (!env) info(`switch: ${target.recovery}`) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,146 @@ | ||||||
| // Where the CLI is pointed, and which setting put it there. | ||||||
| // | ||||||
| // Everything here is DERIVED: nothing is stored, and nothing is asked of the host. That is | ||||||
| // deliberate on both counts. The failure this exists for is a host that does not answer, so an | ||||||
| // answer from the host is exactly what cannot be relied on; and a stored label ("this URL is an | ||||||
| // insta-oss box") goes stale the moment the box is rebuilt or the URL reused, which is worse than | ||||||
| // no label at all. Provenance needs neither: `env use` only ever writes a host from the env table, | ||||||
| // so a persisted apiUrl that is not one of those can only have come from `login --api-url`. | ||||||
| import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, normalizeUrl, type EnvName } from './env.js' | ||||||
| import { storedApiUrl } from './config.js' | ||||||
|
|
||||||
| /** Which setting chose the target. A stable token, so `--json` consumers (agents, mostly) can | ||||||
| * branch on it: the human `source` string is prose and will be reworded. */ | ||||||
| export type TargetSource = | ||||||
| | 'env-api-url' // $INSTA_API_URL | ||||||
| | 'env-name' // $INSTA_ENV | ||||||
| | 'saved-api-url' // persisted by `insta login --api-url` | ||||||
| | 'saved-env' // persisted by `insta env use` / `login --env` | ||||||
| | 'default' // nothing was ever chosen | ||||||
| | 'flag' // --api-url on the running command, not yet persisted | ||||||
|
|
||||||
| export type Target = { | ||||||
| apiUrl: string | ||||||
| host: string | ||||||
| /** null for a host no environment name covers: an insta-oss daemon, a preview, a tunnel. */ | ||||||
| env: EnvName | null | ||||||
| /** Machine-stable; `source` is the same fact as prose. */ | ||||||
| kind: TargetSource | ||||||
| /** The setting that chose apiUrl, phrased to sit after a `source:` label. */ | ||||||
| source: string | ||||||
| /** The one command that gets back to InstaCloud from here. */ | ||||||
| recovery: string | ||||||
| } | ||||||
|
|
||||||
| export const hostOf = (url: string): string => { | ||||||
| try { return new URL(url).host } catch { return url } | ||||||
| } | ||||||
|
|
||||||
| /** Pure core (unit-tested). `stored` is what the config FILE holds, null when it holds nothing. */ | ||||||
| export function buildTarget(i: { | ||||||
| apiUrl: string | ||||||
| stored: string | null | ||||||
| envApiUrl?: string | ||||||
| envName?: string | ||||||
| }): Target { | ||||||
| const want = normalizeUrl(i.apiUrl) | ||||||
| const env = envForApiUrl(i.apiUrl) | ||||||
| const named = i.envName?.trim().toLowerCase() | ||||||
| const namedApi = named && (ENV_NAMES as string[]).includes(named) ? ENVS[named as EnvName].api : undefined | ||||||
|
|
||||||
| let kind: TargetSource | ||||||
| if (i.envApiUrl && normalizeUrl(i.envApiUrl) === want) kind = 'env-api-url' | ||||||
| else if (namedApi && normalizeUrl(namedApi) === want) kind = 'env-name' | ||||||
| else if (i.stored && normalizeUrl(i.stored) === want) { | ||||||
| // A stored host the env table knows was written by `env use` (or by `login --env`, which | ||||||
| // writes the same value); anything else was a literal URL the user typed at `login --api-url`. | ||||||
| kind = envForApiUrl(i.stored) ? 'saved-env' : 'saved-api-url' | ||||||
| } else if (i.stored === null && env === DEFAULT_ENV) kind = 'default' | ||||||
| // Nothing in the environment or on disk accounts for this URL, so it came from the flag the | ||||||
| // running command was given (`login --api-url`, before anything is persisted). | ||||||
| else kind = 'flag' | ||||||
|
|
||||||
| const source = | ||||||
| kind === 'env-api-url' ? 'INSTA_API_URL' | ||||||
| : kind === 'env-name' ? `INSTA_ENV=${named}` | ||||||
| : kind === 'saved-env' ? 'saved by `insta env use`' | ||||||
| : kind === 'saved-api-url' ? 'saved by `insta login --api-url`' | ||||||
| : kind === 'default' ? 'built-in default' | ||||||
| : '--api-url flag' | ||||||
|
|
||||||
| // Undo the thing that actually chose this host, not the thing that usually does. `env use prod` | ||||||
| // is the right advice only when something is PERSISTED; against a bare `--api-url` on the | ||||||
| // running command it is a no-op that prints "already on prod" and deepens the confusion. | ||||||
| const recovery = | ||||||
| kind === 'env-api-url' ? 'unset INSTA_API_URL' | ||||||
| : kind === 'env-name' ? 'unset INSTA_ENV' | ||||||
| : kind === 'flag' ? 'drop --api-url' | ||||||
| : 'insta env use prod' | ||||||
|
|
||||||
| return { apiUrl: i.apiUrl, host: hostOf(i.apiUrl), env, kind, source, recovery } | ||||||
| } | ||||||
|
|
||||||
| /** The live target, from the resolved apiUrl plus the config file and the environment. */ | ||||||
| export async function describeTarget(apiUrl: string): Promise<Target> { | ||||||
| return buildTarget({ | ||||||
| apiUrl, | ||||||
| stored: await storedApiUrl(), | ||||||
| envApiUrl: process.env.INSTA_API_URL, | ||||||
| envName: process.env.INSTA_ENV, | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| /** The context an error adds beneath its first line. Empty on the cloud default: the overwhelming | ||||||
| * majority of runs are there, and a run that is where it expects to be has nothing to explain. */ | ||||||
| export function targetLines(t: Target): string[] { | ||||||
| if (t.env === DEFAULT_ENV) return [] | ||||||
| const lines = [` target: ${t.apiUrl} (${t.source})`] | ||||||
| if (!t.env) lines.push(` for InstaCloud: ${t.recovery}`) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the resolved target is staging, Prompt for AI agents
Suggested change
|
||||||
| return lines | ||||||
| } | ||||||
|
|
||||||
| // Two runtimes, two vocabularies, and the shipped artifact is the one that is easy to forget. | ||||||
| // | ||||||
| // On Node (`npm i -g insta`) undici reports every transport failure as the same | ||||||
| // `TypeError: fetch failed`, with the code that says WHICH failure on `.cause`. On Bun (the | ||||||
| // compiled binaries install.sh serves, so: most users) it is a plain `Error` carrying the code on | ||||||
| // the error ITSELF, no cause at all, spelled in Bun's own CamelCase. | ||||||
| // | ||||||
| // Bun is also coarser, and that part needs care rather than a translation. It reports a connect | ||||||
| // TIMEOUT as `ConnectionRefused`, so it cannot tell a terminated box from a refused port from a | ||||||
| // dead DNS name. Rendering that as "connection refused" would be a confident lie about the exact | ||||||
| // host this feature exists for, so it gets the honest, non-committal "could not connect". Node's | ||||||
| // own ECONNREFUSED really does mean refused and keeps the precise wording. | ||||||
| // | ||||||
| // Anything unmapped falls through to the code, then the message, so a new runtime code degrades to | ||||||
| // a raw-but-present reason rather than to nothing. | ||||||
| const REASONS: Record<string, string> = { | ||||||
| // Bun (the compiled binaries). | ||||||
| ConnectionRefused: 'could not connect', | ||||||
| ConnectionClosed: 'connection closed', | ||||||
| FailedToOpenSocket: 'could not open a socket', | ||||||
| Timeout: 'timed out', | ||||||
| // Node / undici (the npm install). | ||||||
| UND_ERR_CONNECT_TIMEOUT: 'connect timeout', | ||||||
| UND_ERR_HEADERS_TIMEOUT: 'no response headers', | ||||||
| UND_ERR_SOCKET: 'socket closed', | ||||||
| ECONNREFUSED: 'connection refused', | ||||||
| ECONNRESET: 'connection reset', | ||||||
| EHOSTUNREACH: 'host unreachable', | ||||||
| ENETUNREACH: 'network unreachable', | ||||||
| ETIMEDOUT: 'timed out', | ||||||
| ENOTFOUND: 'DNS lookup failed', | ||||||
| EAI_AGAIN: 'DNS temporarily unavailable', | ||||||
| CERT_HAS_EXPIRED: 'TLS certificate expired', | ||||||
| DEPTH_ZERO_SELF_SIGNED_CERT: 'self-signed TLS certificate', | ||||||
| UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS certificate not trusted', | ||||||
| ERR_TLS_CERT_ALTNAME_INVALID: 'TLS certificate does not cover this host', | ||||||
| } | ||||||
|
|
||||||
| /** Why the connection failed, in a few words. Pure (unit-tested). */ | ||||||
| export function failureReason(cause: unknown): string | null { | ||||||
| const c = cause as { code?: string; message?: string } | undefined | ||||||
| if (!c) return null | ||||||
| const known = c.code ? REASONS[c.code] : undefined | ||||||
| return known ?? c.code ?? c.message ?? null | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When deploy remaps a 409 error, it drops the new
ApiError.context, so non-default target failures omit the URL and provenance. Preservee.contextwhen constructing the replacement error.Prompt for AI agents