diff --git a/orbio-openclaw-plugin/src/formatters.ts b/orbio-openclaw-plugin/src/formatters.ts index 6bd2c52..c8fd66f 100644 --- a/orbio-openclaw-plugin/src/formatters.ts +++ b/orbio-openclaw-plugin/src/formatters.ts @@ -1,4 +1,4 @@ -import { OrbioApiError, PluginRateLimitError } from "./http"; +import { OrbioApiError, OrbioInvalidResponseError, PluginRateLimitError } from "./http"; import type { AccountSearchResponse, ExportCreateResponse, @@ -123,6 +123,12 @@ export function errorText(error: unknown): string { return `Rate limited by plugin policy. Retry in ~${error.retryAfterSec}s.`; } + if (error instanceof OrbioInvalidResponseError) { + const status = error.status > 0 ? ` (status=${error.status})` : ""; + const requestIdSuffix = error.requestId ? ` (request_id=${error.requestId})` : ""; + return `Orbio API returned an invalid JSON response.${status}${requestIdSuffix}`; + } + if (error instanceof OrbioApiError) { const code = (error.code ?? "").toLowerCase(); const requestIdSuffix = error.requestId ? ` (request_id=${error.requestId})` : ""; diff --git a/orbio-openclaw-plugin/src/http.ts b/orbio-openclaw-plugin/src/http.ts index 75dcb33..e97d388 100644 --- a/orbio-openclaw-plugin/src/http.ts +++ b/orbio-openclaw-plugin/src/http.ts @@ -35,6 +35,17 @@ export class OrbioApiError extends Error { } } +export class OrbioInvalidResponseError extends Error { + public readonly status: number; + public readonly requestId: string | null; + + constructor(status: number, requestId: string | null) { + super("Orbio API returned an invalid JSON response."); + this.status = status; + this.requestId = requestId; + } +} + export class MinuteWindowLimiter { private readonly events = new Map(); @@ -131,16 +142,16 @@ export class OrbioHttpClient { if (response.status === 204) { return {} as T; } - const payload = await this.parseJsonSafe(response); + const payload = await this.parseJson(response); return (payload ?? {}) as T; } + const payload = await this.parseJson(response); if (response.status >= 500 && attempt < this.cfg.retryCount) { await sleep(this.cfg.retryBackoffMs * (attempt + 1)); continue; } - const payload = await this.parseJsonSafe(response); const { code, detail } = parseProblem(payload); throw new OrbioApiError({ status: response.status, @@ -156,7 +167,7 @@ export class OrbioHttpClient { await sleep(this.cfg.retryBackoffMs * (attempt + 1)); continue; } - if (error instanceof OrbioApiError) { + if (error instanceof OrbioApiError || error instanceof OrbioInvalidResponseError) { throw error; } const detail = isAbort @@ -181,7 +192,7 @@ export class OrbioHttpClient { }); } - private async parseJsonSafe(response: Response): Promise { + private async parseJson(response: Response): Promise { const text = await response.text(); if (!text.trim()) { return null; @@ -189,7 +200,7 @@ export class OrbioHttpClient { try { return JSON.parse(text); } catch { - return null; + throw new OrbioInvalidResponseError(response.status, response.headers.get("X-Request-Id")); } } diff --git a/orbio-openclaw-plugin/tests/index.test.ts b/orbio-openclaw-plugin/tests/index.test.ts index 2f2fe9a..5c433a6 100644 --- a/orbio-openclaw-plugin/tests/index.test.ts +++ b/orbio-openclaw-plugin/tests/index.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import registerOrbioPlugin from "../src/index"; +import { errorText } from "../src/formatters"; +import { OrbioInvalidResponseError } from "../src/http"; type ToolResult = { content: Array<{ type: "text"; text: string }>; @@ -27,6 +29,14 @@ type SetupOptions = { env?: Record; }; +type ErrorMappingCase = { + title: string; + status: number; + payload: unknown; + headers?: Record; + expected: string; +}; + const SAFE_FIELDS = [ "cnpj", "legal_name", @@ -60,13 +70,31 @@ const fetchMock = vi.fn(); function jsonResponse( payload: unknown, status = 200, - headers?: Record, + headers?: Record, ): Response { - return new Response(JSON.stringify(payload), { status, headers }); + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(headers ?? {})) { + if (value !== undefined) { + responseHeaders[key] = value; + } + } + return new Response(JSON.stringify(payload), { + status, + headers: responseHeaders, + }); } -function textResponse(text: string, status = 200, headers?: Record): Response { - return new Response(text, { status, headers }); +function textResponse(text: string, status = 200, headers?: Record): Response { + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(headers ?? {})) { + if (value !== undefined) { + responseHeaders[key] = value; + } + } + return new Response(text, { + status, + headers: responseHeaders, + }); } function capabilitiesResponse(fieldAllowlist: string[] = SAFE_FIELDS): Response { @@ -852,7 +880,7 @@ describe("orbio-openclaw plugin", () => { expect(text).toContain("Orbio API error: Network failure while calling Orbio API."); }); - it.each([ + it.each([ { title: "429 rate limit with retry-after and request id", status: 429, @@ -933,18 +961,26 @@ describe("orbio-openclaw plugin", () => { expect(text).toContain(expected); }); - it("handles non-json error payloads safely", async () => { + it("fails fast on non-json error payloads", async () => { fetchMock .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(textResponse("not-json", 400, { "X-Request-Id": "req-text" })); const { handlers } = setupPlugin(); const text = await invokeTool(handlers, "orbio_search", { query_text: "non-json error" }); - expect(text).toContain("Orbio API error: Orbio API returned an error. (request_id=req-text)"); + expect(text).toBe("Orbio API returned an invalid JSON response. (status=400) (request_id=req-text)"); }); - it("handles non-json successful payloads safely", async () => { - fetchMock.mockResolvedValueOnce(textResponse("ok-but-not-json", 200)); + it("fails fast on non-json successful payloads", async () => { + fetchMock.mockResolvedValueOnce(textResponse("ok-but-not-json", 200, { "X-Request-Id": "req-success-text" })); + const { handlers } = setupPlugin(); + + const text = await invokeTool(handlers, "orbio_export_status", { export_id: "exp-123" }); + expect(text).toBe("Orbio API returned an invalid JSON response. (status=200) (request_id=req-success-text)"); + }); + + it("returns empty object for empty successful payloads", async () => { + fetchMock.mockResolvedValueOnce(textResponse("", 200)); const { handlers } = setupPlugin(); const text = await invokeTool(handlers, "orbio_export_status", { export_id: "exp-123" }); @@ -952,6 +988,10 @@ describe("orbio-openclaw plugin", () => { expect(parseJsonBlock(text)).toEqual({}); }); + it("formats invalid JSON errors without optional context", () => { + expect(errorText(new OrbioInvalidResponseError(0, null))).toBe("Orbio API returned an invalid JSON response."); + }); + it("handles 204 successful payloads safely", async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); const { handlers } = setupPlugin();