File size: 8,862 Bytes
5448d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import { setTimeout as sleep } from "node:timers/promises";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";

export const RETRY_DEFAULTS = Object.freeze({
  maxAttempts: 3,
  baseMs: 500,
  maxMs: 8000,
  jitter: true,
  retryableStatuses: [408, 425, 429, 502, 503, 504],
  retryableErrorCodes: [
    "ECONNRESET",
    "ECONNREFUSED",
    "ETIMEDOUT",
    "ENOTFOUND",
    "EAI_AGAIN",
    "EPIPE",
  ],
});

const NON_RETRYABLE_ON_MUTATION = new Set([409, 422, 429]);
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

export function getBaseUrl(opts = {}) {
  if (opts.baseUrl) return stripTrailingSlash(opts.baseUrl);
  const envUrl = process.env.OMNIROUTE_BASE_URL;
  if (envUrl) return stripTrailingSlash(envUrl);

  try {
    const configPath = join(resolveDataDir(), "config.json");
    if (existsSync(configPath)) {
      const cfg = JSON.parse(readFileSync(configPath, "utf8"));
      const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
      if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
      if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
    }
  } catch {
    // Config read failures are not fatal — fall through to default.
  }

  const port = process.env.PORT || "20128";
  return `http://localhost:${port}`;
}

function stripTrailingSlash(value) {
  const s = String(value);
  let end = s.length;
  while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
  return end === s.length ? s : s.slice(0, end);
}

function resolveUrl(path, opts) {
  if (/^https?:\/\//i.test(path)) return path;
  return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
}

async function buildHeaders(opts) {
  const headers = new Headers(opts.headers || {});
  if (!headers.has("accept")) headers.set("accept", "application/json");
  if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
    headers.set("content-type", "application/json");
  }
  const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
  if (apiKey && !headers.has("authorization")) {
    headers.set("authorization", `Bearer ${apiKey}`);
  }
  // Inject machine-id derived CLI token; env var override for testing.
  const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
  if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
    headers.set(CLI_TOKEN_HEADER, cliToken);
  }
  if (opts.idempotencyKey && !headers.has("idempotency-key")) {
    headers.set("idempotency-key", opts.idempotencyKey);
  }
  return headers;
}

function serializeBody(body, headers) {
  if (body == null) return undefined;
  if (typeof body === "string") return body;
  if (body instanceof Buffer) return body;
  if (body instanceof URLSearchParams) return body;
  if (typeof body.pipe === "function") return body; // stream
  if (headers.get("content-type")?.includes("application/json")) return JSON.stringify(body);
  return JSON.stringify(body);
}

export function computeBackoff(attempt, retryAfterHeader, defaults = RETRY_DEFAULTS) {
  if (retryAfterHeader != null) {
    const secs = Number.parseFloat(String(retryAfterHeader));
    if (Number.isFinite(secs) && secs >= 0) {
      return Math.min(secs * 1000, defaults.maxMs);
    }
  }
  const exp = Math.min(defaults.baseMs * 2 ** (attempt - 1), defaults.maxMs);
  if (!defaults.jitter) return exp;
  const jitter = exp * 0.25 * (Math.random() * 2 - 1);
  return Math.max(0, exp + jitter);
}

export function shouldRetryStatus(status, method, opts = {}) {
  if (opts.retry === false) return false;
  const list = opts.retryableStatuses || RETRY_DEFAULTS.retryableStatuses;
  if (!list.includes(status)) return false;
  if (MUTATING_METHODS.has(method) && NON_RETRYABLE_ON_MUTATION.has(status)) {
    return status === 429 ? Boolean(opts.retryMutationsOn429) : false;
  }
  return true;
}

export function shouldRetryError(err, opts = {}) {
  if (opts.retry === false) return false;
  const codes = opts.retryableErrorCodes || RETRY_DEFAULTS.retryableErrorCodes;
  if (err?.code && codes.includes(err.code)) return true;
  if (err?.name === "AbortError" || /timeout|abort/i.test(err?.message || "")) return true;
  return false;
}

export function statusToExitCode(status) {
  if (status >= 200 && status < 300) return 0;
  if (status === 408) return 124;
  if (status === 401 || status === 403) return 4;
  if (status === 429) return 5;
  if (status === 400 || status === 404 || status === 422) return 2;
  if (status >= 500) return 1;
  return 1;
}

export class ApiError extends Error {
  constructor(message, { status, code, exitCode } = {}) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.code = code;
    this.exitCode = exitCode ?? (status != null ? statusToExitCode(status) : 1);
  }
}

async function readResponseBody(res) {
  const ct = res.headers.get("content-type") || "";
  try {
    if (ct.includes("application/json")) return await res.json();
    return await res.text();
  } catch {
    return null;
  }
}

function fetchOnce(url, init, timeoutMs) {
  if (!timeoutMs) return fetch(url, init);
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), timeoutMs);
  const merged = { ...init, signal: ac.signal };
  return fetch(url, merged).finally(() => clearTimeout(t));
}

export async function apiFetch(path, opts = {}) {
  const method = String(opts.method || "GET").toUpperCase();
  const url = resolveUrl(path, opts);
  const headers = await buildHeaders(opts);
  const body = serializeBody(opts.body, headers);
  const timeout =
    opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
  const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
  const verbose = opts.verbose ?? process.env.OMNIROUTE_VERBOSE === "1";

  let lastErr;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const res = await fetchOnce(url, { method, headers, body }, timeout);
      if (res.ok) return enrichResponse(res, opts);
      if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
        const delay = computeBackoff(attempt, res.headers.get("retry-after"));
        if (verbose) {
          process.stderr.write(
            `[retry ${attempt}/${maxAttempts - 1}] ${method} ${url} → HTTP ${res.status}; wait ${Math.round(delay)}ms\n`
          );
        }
        await sleep(delay);
        continue;
      }
      return enrichResponse(res, opts);
    } catch (err) {
      lastErr = err;
      if (attempt < maxAttempts && shouldRetryError(err, opts)) {
        const delay = computeBackoff(attempt, null);
        if (verbose) {
          process.stderr.write(
            `[retry ${attempt}/${maxAttempts - 1}] ${method} ${url}${err.code || err.message}; wait ${Math.round(delay)}ms\n`
          );
        }
        await sleep(delay);
        continue;
      }
      throw normalizeNetworkError(err);
    }
  }
  throw normalizeNetworkError(lastErr);
}

function enrichResponse(res, opts) {
  res.exitCode = statusToExitCode(res.status);
  res.json = res.json.bind(res);
  res.text = res.text.bind(res);
  if (!res.ok && !opts.acceptNotOk) {
    res.assertOk = async () => {
      const payload = await readResponseBody(res);
      const message = extractErrorMessage(payload, res.status);
      throw new ApiError(message, { status: res.status });
    };
  } else {
    res.assertOk = async () => res;
  }
  return res;
}

function extractErrorMessage(payload, status) {
  if (payload && typeof payload === "object") {
    if (typeof payload.error === "string") return payload.error;
    if (payload.error?.message) return String(payload.error.message);
    if (payload.message) return String(payload.message);
  }
  if (typeof payload === "string" && payload.length < 200) return payload;
  return `HTTP ${status}`;
}

function normalizeNetworkError(err) {
  if (err instanceof ApiError) return err;
  const code = err?.code || (err?.name === "AbortError" ? "ETIMEDOUT" : undefined);
  const exitCode = code === "ETIMEDOUT" ? 124 : 1;
  return new ApiError(err?.message || "network error", { code, exitCode });
}

export async function isServerUp(opts = {}) {
  try {
    const res = await apiFetch("/api/health", {
      ...opts,
      retry: false,
      timeout: opts.timeout ?? 1500,
      acceptNotOk: true,
    });
    return res.ok || res.status < 500;
  } catch {
    return false;
  }
}