mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Map NATS boundary failures to tagged errors
This commit is contained in:
parent
18b27aeba7
commit
00a26b7deb
3 changed files with 207 additions and 21 deletions
138
ts/packages/base/src/__tests__/nats-backend.test.ts
Normal file
138
ts/packages/base/src/__tests__/nats-backend.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeNatsBackend } from "../backend/nats.js";
|
||||
|
||||
const natsMock = vi.hoisted(() => {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const publish = vi.fn();
|
||||
const consumersGet = vi.fn();
|
||||
const consumersAdd = vi.fn();
|
||||
const streamsInfo = vi.fn();
|
||||
const streamsAdd = vi.fn();
|
||||
const next = vi.fn();
|
||||
const ack = vi.fn();
|
||||
const nak = vi.fn();
|
||||
const drain = vi.fn();
|
||||
const headerAppend = vi.fn();
|
||||
const headers = vi.fn();
|
||||
const connect = vi.fn();
|
||||
|
||||
return {
|
||||
ack,
|
||||
connect,
|
||||
consumersAdd,
|
||||
consumersGet,
|
||||
decoder,
|
||||
drain,
|
||||
encoder,
|
||||
headerAppend,
|
||||
headers,
|
||||
nak,
|
||||
next,
|
||||
publish,
|
||||
streamsAdd,
|
||||
streamsInfo,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("nats", () => ({
|
||||
AckPolicy: { Explicit: "explicit" },
|
||||
DeliverPolicy: { All: "all", New: "new" },
|
||||
StringCodec: () => ({
|
||||
decode: (input: Uint8Array) => natsMock.decoder.decode(input),
|
||||
encode: (input: string) => natsMock.encoder.encode(input),
|
||||
}),
|
||||
connect: natsMock.connect,
|
||||
headers: natsMock.headers,
|
||||
}));
|
||||
|
||||
function resetNatsMock(): void {
|
||||
vi.clearAllMocks();
|
||||
|
||||
natsMock.publish.mockResolvedValue({ duplicate: false, seq: 1, stream: "tg_test" });
|
||||
natsMock.consumersGet.mockResolvedValue({ next: natsMock.next });
|
||||
natsMock.consumersAdd.mockResolvedValue(undefined);
|
||||
natsMock.streamsInfo.mockResolvedValue({ config: { name: "tg_test" } });
|
||||
natsMock.streamsAdd.mockResolvedValue(undefined);
|
||||
natsMock.next.mockResolvedValue({
|
||||
ack: natsMock.ack,
|
||||
data: natsMock.encoder.encode(JSON.stringify("payload")),
|
||||
headers: undefined,
|
||||
nak: natsMock.nak,
|
||||
});
|
||||
natsMock.ack.mockReturnValue(undefined);
|
||||
natsMock.nak.mockReturnValue(undefined);
|
||||
natsMock.drain.mockResolvedValue(undefined);
|
||||
natsMock.headerAppend.mockReturnValue(undefined);
|
||||
natsMock.headers.mockReturnValue({ append: natsMock.headerAppend });
|
||||
natsMock.connect.mockResolvedValue({
|
||||
drain: natsMock.drain,
|
||||
jetstream: () => ({
|
||||
consumers: { get: natsMock.consumersGet },
|
||||
publish: natsMock.publish,
|
||||
}),
|
||||
jetstreamManager: () =>
|
||||
Promise.resolve({
|
||||
consumers: { add: natsMock.consumersAdd },
|
||||
streams: {
|
||||
add: natsMock.streamsAdd,
|
||||
info: natsMock.streamsInfo,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("NATS backend", () => {
|
||||
beforeEach(() => {
|
||||
resetNatsMock();
|
||||
});
|
||||
|
||||
it("maps invalid publish headers to tagged PubSubError", async () => {
|
||||
natsMock.headerAppend.mockImplementation(() => {
|
||||
throw "invalid header";
|
||||
});
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
const producer = await backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||
|
||||
const error = await producer.send("hello", { bad: "value" }).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
operation: "headers:tg.test.topic",
|
||||
});
|
||||
expect(natsMock.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps thrown ack and nak failures to tagged PubSubError", async () => {
|
||||
natsMock.ack.mockImplementation(() => {
|
||||
throw "ack failed";
|
||||
});
|
||||
natsMock.nak.mockImplementation(() => {
|
||||
throw "nak failed";
|
||||
});
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
const consumer = await backend.createConsumer<string>({
|
||||
topic: "tg.test.topic",
|
||||
subscription: "worker",
|
||||
});
|
||||
const message = await consumer.receive(1);
|
||||
|
||||
expect(message).not.toBeNull();
|
||||
if (message === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ackError = await consumer.acknowledge(message).catch((caught: unknown) => caught);
|
||||
const nakError = await consumer.negativeAcknowledge(message).catch((caught: unknown) => caught);
|
||||
|
||||
expect(ackError).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
operation: "acknowledge:tg.test.topic",
|
||||
});
|
||||
expect(nakError).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
operation: "negative-acknowledge:tg.test.topic",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,9 @@ import {
|
|||
type JetStreamClient,
|
||||
type JetStreamManager,
|
||||
type Consumer as NatsJsConsumer,
|
||||
headers,
|
||||
type JsMsg,
|
||||
type JetStreamPublishOptions,
|
||||
StringCodec,
|
||||
AckPolicy,
|
||||
DeliverPolicy,
|
||||
|
|
@ -78,6 +80,25 @@ function makeNatsProducer<T>(
|
|||
subject: string,
|
||||
schema?: S.Codec<T, unknown>,
|
||||
): BackendProducer<T> {
|
||||
const makePublishOptions = (
|
||||
properties: Record<string, string> | undefined,
|
||||
): Effect.Effect<Partial<JetStreamPublishOptions>, ReturnType<typeof pubSubError>> => {
|
||||
if (properties === undefined || Object.keys(properties).length === 0) {
|
||||
return Effect.succeed({});
|
||||
}
|
||||
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const hdrs = headers();
|
||||
for (const [key, val] of Object.entries(properties)) {
|
||||
hdrs.append(key, val);
|
||||
}
|
||||
return { headers: hdrs };
|
||||
},
|
||||
catch: (error) => pubSubError(`headers:${subject}`, error),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
send: (message, properties) =>
|
||||
Effect.runPromise(
|
||||
|
|
@ -91,19 +112,7 @@ function makeNatsProducer<T>(
|
|||
Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)),
|
||||
);
|
||||
const data = sc.encode(json);
|
||||
const opts: Record<string, unknown> = {};
|
||||
|
||||
if (properties !== undefined && Object.keys(properties).length > 0) {
|
||||
const { headers } = yield* Effect.tryPromise({
|
||||
try: () => import("nats"),
|
||||
catch: (error) => pubSubError("import:nats-headers", error),
|
||||
});
|
||||
const hdrs = headers();
|
||||
for (const [key, val] of Object.entries(properties)) {
|
||||
hdrs.append(key, val);
|
||||
}
|
||||
opts.headers = hdrs;
|
||||
}
|
||||
const opts = yield* makePublishOptions(properties);
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => js.publish(subject, data, opts),
|
||||
|
|
@ -204,8 +213,11 @@ function makeNatsConsumer<T>(
|
|||
if (!isNatsMessage(message)) {
|
||||
return yield* pubSubError(`acknowledge:${subject}`, "Message was not produced by NATS backend");
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
message._jsMsg.ack();
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.ack();
|
||||
},
|
||||
catch: (error) => pubSubError(`acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
|
@ -218,8 +230,11 @@ function makeNatsConsumer<T>(
|
|||
"Message was not produced by NATS backend",
|
||||
);
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
message._jsMsg.nak();
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.nak();
|
||||
},
|
||||
catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue