文件预览

connect.js

查看 FTTR Operator Copilot 技能包中的文件内容。

文件内容

src/connect.js

const TRANSIENT_STATUS = new Set([408, 429, 500, 502, 503, 504]);

export class ConnectClient {
  constructor(config) {
    this.config = config;
  }

  async unary(procedure, body = {}) {
    const url = `${this.config.baseUrl}${procedure}`;
    let lastError;

    for (let attempt = 0; attempt <= this.config.maxRetries; attempt += 1) {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);

      try {
        const response = await fetch(url, {
          method: "POST",
          headers: {
            "Authorization": `Bearer ${this.config.token}`,
            "Content-Type": "application/json",
            "Accept": "application/json",
          },
          body: JSON.stringify(body),
          signal: controller.signal,
        });

        const payload = await readJson(response);
        if (response.ok) {
          return payload;
        }

        const rpcError = connectErrorFromResponse(response, payload, procedure);
        if (!TRANSIENT_STATUS.has(response.status) || attempt === this.config.maxRetries) {
          throw rpcError;
        }
        lastError = rpcError;
      } catch (error) {
        const normalized = normalizeFetchError(error, procedure);
        if (!isRetryableError(normalized) || attempt === this.config.maxRetries) {
          throw normalized;
        }
        lastError = normalized;
      } finally {
        clearTimeout(timeout);
      }

      await delay(200 * (attempt + 1));
    }

    throw lastError;
  }
}

async function readJson(response) {
  const text = await response.text();
  if (!text.trim()) {
    return {};
  }
  try {
    return JSON.parse(text);
  } catch {
    return { raw: text };
  }
}

function connectErrorFromResponse(response, payload, procedure) {
  const message = payload?.message || payload?.error || `RPC 调用失败: HTTP ${response.status}`;
  const err = new Error(message);
  err.code = payload?.code || httpStatusToCode(response.status);
  err.status = response.status;
  err.procedure = procedure;
  err.details = payload;
  return err;
}

function normalizeFetchError(error, procedure) {
  if (error?.code && error?.procedure) {
    return error;
  }
  const err = new Error(error?.name === "AbortError" ? "RPC 调用超时" : error?.message || "RPC 调用失败");
  err.code = error?.name === "AbortError" ? "deadline_exceeded" : "unavailable";
  err.procedure = procedure;
  return err;
}

function isRetryableError(error) {
  return ["deadline_exceeded", "unavailable", "resource_exhausted"].includes(error?.code);
}

function httpStatusToCode(status) {
  switch (status) {
    case 400:
      return "invalid_argument";
    case 401:
      return "unauthenticated";
    case 403:
      return "permission_denied";
    case 404:
      return "not_found";
    case 408:
      return "deadline_exceeded";
    case 429:
      return "resource_exhausted";
    default:
      return status >= 500 ? "unavailable" : "unknown";
  }
}

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}