Skip to content
Open
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
8 changes: 7 additions & 1 deletion orbio-openclaw-plugin/src/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OrbioApiError, PluginRateLimitError } from "./http";
import { OrbioApiError, OrbioInvalidResponseError, PluginRateLimitError } from "./http";
import type {
AccountSearchResponse,
ExportCreateResponse,
Expand Down Expand Up @@ -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})` : "";
Expand Down
21 changes: 16 additions & 5 deletions orbio-openclaw-plugin/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>();

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -181,15 +192,15 @@ export class OrbioHttpClient {
});
}

private async parseJsonSafe(response: Response): Promise<unknown | null> {
private async parseJson(response: Response): Promise<unknown | null> {
const text = await response.text();
if (!text.trim()) {
return null;
}
try {
return JSON.parse(text);
} catch {
return null;
throw new OrbioInvalidResponseError(response.status, response.headers.get("X-Request-Id"));
}
}

Expand Down
58 changes: 49 additions & 9 deletions orbio-openclaw-plugin/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -27,6 +29,14 @@ type SetupOptions = {
env?: Record<string, string | undefined>;
};

type ErrorMappingCase = {
title: string;
status: number;
payload: unknown;
headers?: Record<string, string>;
expected: string;
};

const SAFE_FIELDS = [
"cnpj",
"legal_name",
Expand Down Expand Up @@ -60,13 +70,31 @@ const fetchMock = vi.fn();
function jsonResponse(
payload: unknown,
status = 200,
headers?: Record<string, string>,
headers?: Record<string, string | undefined>,
): Response {
return new Response(JSON.stringify(payload), { status, headers });
const responseHeaders: Record<string, string> = {};
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<string, string>): Response {
return new Response(text, { status, headers });
function textResponse(text: string, status = 200, headers?: Record<string, string | undefined>): Response {
const responseHeaders: Record<string, string> = {};
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 {
Expand Down Expand Up @@ -852,7 +880,7 @@ describe("orbio-openclaw plugin", () => {
expect(text).toContain("Orbio API error: Network failure while calling Orbio API.");
});

it.each([
it.each<ErrorMappingCase>([
{
title: "429 rate limit with retry-after and request id",
status: 429,
Expand Down Expand Up @@ -933,25 +961,37 @@ 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" });
expect(text).toContain("Export status:");
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();
Expand Down
Loading