Initial Commit
This commit is contained in:
75
packages/foundation/flow/queue-distribution-http/README.md
Normal file
75
packages/foundation/flow/queue-distribution-http/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# @suaveplan/queue-distribution-http - HTTP transport plugin for @suaveplan/queue-distribution: HttpWorkerCommunicator (fetch-backed, Zod-validated wire envelope) and RemoteWorker (AbstractWorker subclass with @suaveplan/retry-backed connect / reconnect / health loop).
|
||||
|
||||
## Summary
|
||||
|
||||
HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelope, injectable `fetch`, structured timeouts, and AbortSignal threading) and `RemoteWorker` (an `AbstractWorker` subclass driving the communicator with bounded exponential-backoff connect retries via `@suaveplan/retry`).
|
||||
|
||||
The plugin lives separately from the core distribution package so browser-only and worker-thread-only consumers don't have to carry HTTP transport code they never use.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ - [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/queue-distribution-http
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { WorkerPool, WorkerManager } from "@suaveplan/queue-distribution";
|
||||
import {
|
||||
HttpWorkerCommunicator,
|
||||
RemoteWorker,
|
||||
} from "@suaveplan/queue-distribution-http";
|
||||
|
||||
const communicator = new HttpWorkerCommunicator({
|
||||
defaultTimeoutMs: 5_000,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const pool = new WorkerPool();
|
||||
pool.addWorker(new RemoteWorker("https://worker-a.example", communicator, { id: "a" }));
|
||||
pool.addWorker(new RemoteWorker("https://worker-b.example", communicator, { id: "b" }));
|
||||
await pool.startAllWorkers();
|
||||
|
||||
const manager = new WorkerManager(pool, {
|
||||
autoHealthCheck: false,
|
||||
maxTaskRetries: 3,
|
||||
});
|
||||
|
||||
const result = await manager.assignTask(task, input);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [Errors](./src/errors/queue-distribution-http-error.md) — `QueueDistributionHttpError` + 6 stable `HTTP.*` codes.
|
||||
- [WorkerCommunicator base](./src/communicator/base/base.md) — interface + `AbstractWorkerCommunicator`.
|
||||
- [HttpWorkerCommunicator](./src/communicator/http/http-communicator.md) — fetch-backed transport.
|
||||
- [RemoteWorker](./src/worker/remote/remote.md) — connect / reconnect / health loop.
|
||||
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracers.
|
||||
|
||||
## Design highlights
|
||||
|
||||
- 100% test coverage (statements + branches + functions + lines).
|
||||
- Injectable `fetch` and `Clock` — deterministic under test, swappable for auth-wrapped fetch in production.
|
||||
- Connect retries routed through `@suaveplan/retry`'s `sleep`; bounded loop, no recursion.
|
||||
- Zod-validated wire envelope rejects malformed payloads at the boundary.
|
||||
- Structured `QueueDistributionHttpError` subclasses with a frozen stable-code registry.
|
||||
|
||||
## Status
|
||||
|
||||
Part of the rebuild — version `0.1.0` while it stabilises.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@suaveplan/queue-distribution-http",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "HTTP transport plugin for @suaveplan/queue-distribution: HttpWorkerCommunicator (fetch-backed, Zod-validated wire envelope) and RemoteWorker (AbstractWorker subclass with @suaveplan/retry-backed connect / reconnect / health loop).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/queue-distribution-http-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/queue-distribution-http-error.d.ts",
|
||||
"default": "./dist/errors/queue-distribution-http-error.js"
|
||||
}
|
||||
},
|
||||
"./communicator/base/base": {
|
||||
"import": {
|
||||
"types": "./dist/communicator/base/base.d.ts",
|
||||
"default": "./dist/communicator/base/base.js"
|
||||
}
|
||||
},
|
||||
"./communicator/http/http-communicator": {
|
||||
"import": {
|
||||
"types": "./dist/communicator/http/http-communicator.d.ts",
|
||||
"default": "./dist/communicator/http/http-communicator.js"
|
||||
}
|
||||
},
|
||||
"./worker/remote/remote": {
|
||||
"import": {
|
||||
"types": "./dist/worker/remote/remote.d.ts",
|
||||
"default": "./dist/worker/remote/remote.js"
|
||||
}
|
||||
},
|
||||
"./observability/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/observability/instruments.d.ts",
|
||||
"default": "./dist/observability/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/queue-distribution": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `WorkerCommunicator` + `AbstractWorkerCommunicator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`WorkerCommunicator` is the structural contract every transport implementation in `@suaveplan/queue-distribution-http` (and future RPC transports outside this package) implements. `AbstractWorkerCommunicator` carries the shared bookkeeping — default-timeout handling, header carriage, JSON serialise/deserialise helpers, and the `withTimeout` race used by every send/check call — so concrete subclasses focus on wire-level details. Stateless transports get connect/disconnect no-op defaults.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendTask<I, O>(endpoint, task, input, signal?)` — preserves `<I, O>` end-to-end.
|
||||
- `checkStatus`, `checkHealth`, `ping`, `connect`, `disconnect` — full transport surface.
|
||||
- `getProtocol(): string` — short identifier for logs and capabilities.
|
||||
- `withTimeout(operation, promise, timeoutMs?)` — Promise.race against an injectable timeout that rejects with `HTTP.TIMEOUT`.
|
||||
- Configurable `defaultTimeoutMs` and per-request `headers`.
|
||||
- Injectable `Clock` for tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractWorkerCommunicator,
|
||||
type WorkerCommunicator,
|
||||
} from "@suaveplan/queue-distribution-http/communicator/base/base";
|
||||
import type {
|
||||
DistributedTask,
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
|
||||
class EchoCommunicator extends AbstractWorkerCommunicator {
|
||||
async sendTask<I, O>(_e, _t, input: I): Promise<O> {
|
||||
return input as unknown as O;
|
||||
}
|
||||
async checkStatus(): Promise<WorkerStatusValue> {
|
||||
return "ONLINE";
|
||||
}
|
||||
async checkHealth(): Promise<WorkerHealth> {
|
||||
return {
|
||||
isHealthy: true,
|
||||
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
|
||||
};
|
||||
}
|
||||
async ping(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
getProtocol(): string {
|
||||
return "echo";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Subclasses can ride on `withTimeout` to enforce a per-call deadline without duplicating timer plumbing.
|
||||
|
||||
```ts
|
||||
import { AbstractWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/base/base";
|
||||
|
||||
class SlowCommunicator extends AbstractWorkerCommunicator {
|
||||
async sendTask(): Promise<unknown> {
|
||||
return this.withTimeout("sendTask", longRunningRpc(), 2_000);
|
||||
}
|
||||
// … rest of interface …
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WorkerCommunicator` — public surface.
|
||||
- `abstract class AbstractWorkerCommunicator implements WorkerCommunicator`.
|
||||
- `constructor(options?: { defaultTimeoutMs?, headers? }, deps?: { clock? })`.
|
||||
- `protected async withTimeout<T>(operation, promise, timeoutMs?): Promise<T>`.
|
||||
- `protected serialize(value): string`, `protected deserialize<T>(s): T`.
|
||||
- `connect`, `disconnect` — overridable no-ops.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withTimeout` attaches a stateful `then` handler to the source promise so we can `clearTimeout` from the resolution path, and races against a deferred `timeoutSignal`. This avoids the unhandled-rejection trap of using a freestanding `setTimeout(..., reject)` Promise alongside `Promise.race`: the source promise's settlement always clears the timer before the rejection can fire late. Non-Error rejections from `promise` are wrapped in `new Error(String(err))` so the error chain stays well-typed for downstream `SuaveplanError` consumers.
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HttpTimeoutError } from "../../errors/queue-distribution-http-error.js";
|
||||
import { AbstractWorkerCommunicator } from "./base.js";
|
||||
|
||||
class TestCommunicator extends AbstractWorkerCommunicator {
|
||||
async sendTask<_I, O>(): Promise<O> {
|
||||
return undefined as never;
|
||||
}
|
||||
async checkStatus(): Promise<WorkerStatusValue> {
|
||||
return "ONLINE";
|
||||
}
|
||||
async checkHealth(): Promise<WorkerHealth> {
|
||||
return {
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
async ping(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
getProtocol(): string {
|
||||
return "test";
|
||||
}
|
||||
public async testTimeout<T>(promise: Promise<T>, ms?: number): Promise<T> {
|
||||
return this.withTimeout("test", promise, ms);
|
||||
}
|
||||
public testSerde(value: unknown): unknown {
|
||||
const s = this.serialize(value);
|
||||
return this.deserialize(s);
|
||||
}
|
||||
}
|
||||
|
||||
describe("AbstractWorkerCommunicator", () => {
|
||||
it("connect / disconnect have no-op defaults", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator();
|
||||
await expect(c.connect("ep")).resolves.toBeUndefined();
|
||||
await expect(c.disconnect("ep")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("withTimeout resolves a fast promise before the deadline fires", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator();
|
||||
const result = await c.testTimeout(Promise.resolve(42), 100);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("withTimeout rejects with HTTP.TIMEOUT after the deadline", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator();
|
||||
const pending = new Promise<never>(() => {});
|
||||
const handled = c.testTimeout(pending, 50).catch((err) => err);
|
||||
await expect(handled).resolves.toBeInstanceOf(HttpTimeoutError);
|
||||
});
|
||||
|
||||
it("withTimeout uses defaultTimeoutMs when no override is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator({ defaultTimeoutMs: 50 });
|
||||
const pending = new Promise<never>(() => {});
|
||||
const handled = c.testTimeout(pending).catch((err) => err);
|
||||
await expect(handled).resolves.toBeInstanceOf(HttpTimeoutError);
|
||||
});
|
||||
|
||||
it("withTimeout coerces non-Error rejections to Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator();
|
||||
const handled = c
|
||||
.testTimeout(Promise.reject("boom"), 1_000)
|
||||
.catch((err) => err);
|
||||
await expect(handled).resolves.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("withTimeout forwards thrown Error rejections", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = new TestCommunicator();
|
||||
const failure = new Error("nope");
|
||||
const handled = c
|
||||
.testTimeout(Promise.reject(failure), 1_000)
|
||||
.catch((err) => err);
|
||||
await expect(handled).resolves.toBe(failure);
|
||||
});
|
||||
|
||||
it("serialise / deserialise round-trip JSON", { timeout: 5000 }, () => {
|
||||
const c = new TestCommunicator();
|
||||
expect(c.testSerde({ a: 1, b: ["x"] })).toEqual({ a: 1, b: ["x"] });
|
||||
});
|
||||
|
||||
it("constructor defaults apply when no options or deps are passed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestCommunicator();
|
||||
expect(c.getProtocol()).toBe("test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
DistributedTask,
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { HttpTimeoutError } from "../../errors/queue-distribution-http-error.js";
|
||||
|
||||
/**
|
||||
* Pluggable contract for talking to a remote worker. Implementations can pick
|
||||
* the wire format (HTTP, WebSocket, gRPC, …) and the transport layer.
|
||||
*/
|
||||
export interface WorkerCommunicator {
|
||||
sendTask<I, O>(
|
||||
endpoint: string,
|
||||
task: DistributedTask<I, O>,
|
||||
input: I,
|
||||
signal?: AbortSignal
|
||||
): Promise<O>;
|
||||
checkStatus(
|
||||
endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerStatusValue>;
|
||||
checkHealth(endpoint: string, signal?: AbortSignal): Promise<WorkerHealth>;
|
||||
connect(endpoint: string, signal?: AbortSignal): Promise<void>;
|
||||
disconnect(endpoint: string, signal?: AbortSignal): Promise<void>;
|
||||
ping(
|
||||
endpoint: string,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<number>;
|
||||
getProtocol(): string;
|
||||
}
|
||||
|
||||
export interface AbstractWorkerCommunicatorDeps {
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for `WorkerCommunicator` implementations. Provides
|
||||
* default-timeout bookkeeping, header carriage, JSON serialise/deserialise
|
||||
* helpers, and a `withTimeout` race used by every sendTask/check.
|
||||
*/
|
||||
export abstract class AbstractWorkerCommunicator implements WorkerCommunicator {
|
||||
protected readonly defaultTimeoutMs: number;
|
||||
protected readonly headers: Readonly<Record<string, string>>;
|
||||
protected readonly clock: Clock;
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
defaultTimeoutMs?: number;
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
} = {},
|
||||
deps: AbstractWorkerCommunicatorDeps = {}
|
||||
) {
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000;
|
||||
this.headers = options.headers ?? {};
|
||||
this.clock = deps.clock ?? Date.now;
|
||||
}
|
||||
|
||||
abstract sendTask<I, O>(
|
||||
endpoint: string,
|
||||
task: DistributedTask<I, O>,
|
||||
input: I,
|
||||
signal?: AbortSignal
|
||||
): Promise<O>;
|
||||
abstract checkStatus(
|
||||
endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerStatusValue>;
|
||||
abstract checkHealth(
|
||||
endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerHealth>;
|
||||
abstract ping(
|
||||
endpoint: string,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<number>;
|
||||
abstract getProtocol(): string;
|
||||
|
||||
async connect(_endpoint: string, _signal?: AbortSignal): Promise<void> {
|
||||
// Default: stateless transports have nothing to do at connect time.
|
||||
}
|
||||
|
||||
async disconnect(_endpoint: string, _signal?: AbortSignal): Promise<void> {
|
||||
// Default: stateless transports have nothing to do at disconnect time.
|
||||
}
|
||||
|
||||
protected serialize(data: unknown): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
protected deserialize<T>(data: string): T {
|
||||
return JSON.parse(data) as T;
|
||||
}
|
||||
|
||||
protected async withTimeout<T>(
|
||||
operation: string,
|
||||
promise: Promise<T>,
|
||||
timeoutMs?: number
|
||||
): Promise<T> {
|
||||
const deadline = timeoutMs ?? this.defaultTimeoutMs;
|
||||
let timer!: ReturnType<typeof setTimeout>;
|
||||
const timeoutSignal = new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new HttpTimeoutError({ operation, timeoutMs: deadline })
|
||||
);
|
||||
}, deadline);
|
||||
});
|
||||
const safeOriginal = promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
return value;
|
||||
},
|
||||
(err: unknown) => {
|
||||
clearTimeout(timer);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
);
|
||||
return Promise.race([safeOriginal, timeoutSignal]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# `HttpWorkerCommunicator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`HttpWorkerCommunicator` is the `WorkerCommunicator` implementation for talking to a remote worker over HTTP(S). It uses an injectable `fetch` (defaulting to `globalThis.fetch`) so tests can drive deterministic request/response sequences without an actual server. Every response is validated against a Zod envelope (`{ success: true, data }` | `{ success: false, error }`) so malformed payloads are rejected at the boundary rather than smuggled in as `unknown` data.
|
||||
|
||||
## Features
|
||||
|
||||
- Endpoints used: `POST /execute`, `GET /status`, `GET /health`, `GET /ping`.
|
||||
- Zod-validated wire envelope (`workerApiResponseSchema(dataSchema)`).
|
||||
- Structured error mapping: `RESPONSE_MALFORMED` for envelope mismatch, `PROTOCOL_ERROR` for upstream-reported failures and non-2xx with a successful envelope, `REQUEST_FAILED` for network rejections including `AbortError`.
|
||||
- Configurable headers and `defaultTimeoutMs`; injectable `fetch` and `Clock`.
|
||||
- OTel histogram + counter per request, dimensioned by `outcome`.
|
||||
- AbortSignal threaded into `fetch.init.signal`.
|
||||
- `getProtocol()` returns `"http"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
|
||||
|
||||
const communicator = new HttpWorkerCommunicator({
|
||||
defaultTimeoutMs: 5_000,
|
||||
headers: { "X-Tenant": "acme" },
|
||||
});
|
||||
|
||||
const result = await communicator.sendTask("https://worker.example", task, input);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a deterministic `fetch` for tests, or wrap it with a custom auth + retry layer in production.
|
||||
|
||||
```ts
|
||||
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
|
||||
|
||||
const communicator = new HttpWorkerCommunicator(
|
||||
{ defaultTimeoutMs: 10_000, headers: { Authorization: `Bearer ${token}` } },
|
||||
{
|
||||
fetch: async (url, init) => {
|
||||
const response = await globalThis.fetch(url, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
});
|
||||
return response;
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class HttpWorkerCommunicator extends AbstractWorkerCommunicator`.
|
||||
- `constructor(options?: HttpWorkerCommunicatorOptions, deps?: HttpWorkerCommunicatorDeps)`.
|
||||
- `interface HttpWorkerCommunicatorOptions` — `defaultTimeoutMs?`, `headers?`.
|
||||
- `interface HttpWorkerCommunicatorDeps` — `clock?`, `fetch?`.
|
||||
- `type FetchLike` — minimal `fetch` shape (`ok`, `status`, `statusText`, `json()`).
|
||||
- `function workerApiResponseSchema<T>(dataSchema)` — produces the Zod envelope for any payload shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire envelope is a discriminated union (`success: true` carries `data`, `success: false` carries `error`). The communicator treats non-2xx HTTP status as a protocol error even when the envelope says `success: true` — that protects against misbehaving servers returning 500 with a "this somehow worked" payload. Headers are spread at request time so per-call overrides are not yet supported; if needed, subclass and override `#makeRequest`.
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { DistributedTask } from "@suaveplan/queue-distribution";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HttpProtocolError,
|
||||
HttpRequestFailedError,
|
||||
HttpResponseMalformedError,
|
||||
} from "../../errors/queue-distribution-http-error.js";
|
||||
import { type FetchLike, HttpWorkerCommunicator } from "./http-communicator.js";
|
||||
|
||||
function makeTask<I, O>(id = "t"): DistributedTask<I, O> {
|
||||
let status = "PENDING" as string;
|
||||
return {
|
||||
id,
|
||||
execute: async () => undefined as never,
|
||||
getStatus: () => status as never,
|
||||
setStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeFetch(
|
||||
impl: (
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
json: () => Promise<unknown>;
|
||||
}>
|
||||
): FetchLike {
|
||||
return impl;
|
||||
}
|
||||
|
||||
describe("HttpWorkerCommunicator", () => {
|
||||
it("sendTask resolves with typed data on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: "OK" }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
expect(await c.sendTask("http://x", makeTask("t"), "in")).toBe("OK");
|
||||
});
|
||||
|
||||
it("getProtocol returns 'http'", { timeout: 5000 }, () => {
|
||||
expect(new HttpWorkerCommunicator().getProtocol()).toBe("http");
|
||||
});
|
||||
|
||||
it("rejects with HTTP.RESPONSE_MALFORMED when envelope shape is wrong", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ foo: 1 }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
await expect(
|
||||
c.sendTask("http://x", makeTask("t"), "in")
|
||||
).rejects.toThrow(HttpResponseMalformedError);
|
||||
});
|
||||
|
||||
it("rejects with HTTP.PROTOCOL_ERROR and carries upstream message on cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: { code: "INTERNAL", message: "boom" },
|
||||
}),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
await expect(
|
||||
c.sendTask("http://x", makeTask("t"), "in")
|
||||
).rejects.toMatchObject({ code: "HTTP.PROTOCOL_ERROR" });
|
||||
});
|
||||
|
||||
it("rejects with HTTP.PROTOCOL_ERROR when envelope is success but HTTP is non-2xx", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "boom",
|
||||
json: async () => ({ success: true, data: "OK" }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
await expect(
|
||||
c.sendTask("http://x", makeTask("t"), "in")
|
||||
).rejects.toThrow(HttpProtocolError);
|
||||
});
|
||||
|
||||
it("network failure surfaces as HTTP.REQUEST_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = fakeFetch(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
});
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
await expect(
|
||||
c.sendTask("http://x", makeTask("t"), "in")
|
||||
).rejects.toThrow(HttpRequestFailedError);
|
||||
});
|
||||
|
||||
it("forwards AbortSignal to the injected fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let sawAborted = false;
|
||||
const fetcher = fakeFetch(async (_url, init) => {
|
||||
sawAborted = init?.signal?.aborted ?? false;
|
||||
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||
});
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
c.sendTask("http://x", makeTask("t"), "in", ctrl.signal)
|
||||
).rejects.toThrow(HttpRequestFailedError);
|
||||
expect(sawAborted).toBe(true);
|
||||
});
|
||||
|
||||
it("checkStatus parses the typed enum", { timeout: 5000 }, async () => {
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: "BUSY" }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
expect(await c.checkStatus("http://x")).toBe("BUSY");
|
||||
});
|
||||
|
||||
it("checkHealth parses the worker-health shape", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const health = {
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 1,
|
||||
memory: 2,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 1,
|
||||
responseTime: 10,
|
||||
},
|
||||
};
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: health }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
expect(await c.checkHealth("http://x")).toEqual(health);
|
||||
});
|
||||
|
||||
it("ping returns elapsed milliseconds from injected Clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Clock is consumed by ping() start, #makeRequest start + end, ping() end.
|
||||
const ticks = [1000, 1000, 1200, 1500];
|
||||
let i = 0;
|
||||
const fetcher = fakeFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: { timestamp: 1 } }),
|
||||
}));
|
||||
const c = new HttpWorkerCommunicator(
|
||||
{},
|
||||
{ fetch: fetcher, clock: () => ticks[i++ % ticks.length] ?? 0 }
|
||||
);
|
||||
expect(await c.ping("http://x", 1000)).toBe(500);
|
||||
});
|
||||
|
||||
it("handles endpoints with trailing slash and path without leading slash", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let observedUrl = "";
|
||||
const fetcher = fakeFetch(async (url) => {
|
||||
observedUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: "ONLINE" }),
|
||||
};
|
||||
});
|
||||
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
|
||||
await c.checkStatus("http://x/");
|
||||
expect(observedUrl).toBe("http://x/status");
|
||||
});
|
||||
|
||||
it("propagates configured headers", { timeout: 5000 }, async () => {
|
||||
let observedHeaders: Record<string, string> = {};
|
||||
const fetcher = fakeFetch(async (_url, init) => {
|
||||
observedHeaders = (init?.headers ?? {}) as Record<string, string>;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ success: true, data: "ONLINE" }),
|
||||
};
|
||||
});
|
||||
const c = new HttpWorkerCommunicator(
|
||||
{ headers: { "X-Auth": "secret" } },
|
||||
{ fetch: fetcher }
|
||||
);
|
||||
await c.checkStatus("http://x");
|
||||
expect(observedHeaders["X-Auth"]).toBe("secret");
|
||||
});
|
||||
|
||||
it("defaults to globalThis.fetch when no fetch is injected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Just verify construction succeeds; the underlying global is not invoked here.
|
||||
const c = new HttpWorkerCommunicator();
|
||||
expect(c).toBeInstanceOf(HttpWorkerCommunicator);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type {
|
||||
DistributedTask,
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import z from "zod";
|
||||
import {
|
||||
HttpProtocolError,
|
||||
HttpRequestFailedError,
|
||||
HttpResponseMalformedError,
|
||||
} from "../../errors/queue-distribution-http-error.js";
|
||||
import {
|
||||
recordHttpRequestDuration,
|
||||
recordHttpRequestOutcome,
|
||||
} from "../../observability/instruments.js";
|
||||
import {
|
||||
AbstractWorkerCommunicator,
|
||||
type AbstractWorkerCommunicatorDeps,
|
||||
} from "../base/base.js";
|
||||
|
||||
/** Zod schema for the standard wire envelope every endpoint returns. */
|
||||
export function workerApiResponseSchema<T>(dataSchema: z.ZodType<T>) {
|
||||
return z.union([
|
||||
z.object({ success: z.literal(true), data: dataSchema }),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
json(): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
export interface HttpWorkerCommunicatorOptions {
|
||||
defaultTimeoutMs?: number;
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface HttpWorkerCommunicatorDeps
|
||||
extends AbstractWorkerCommunicatorDeps {
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
const STATUS_SCHEMA = z.enum(["ONLINE", "BUSY", "OFFLINE", "UNHEALTHY"]);
|
||||
|
||||
const HEALTH_SCHEMA = z.object({
|
||||
isHealthy: z.boolean(),
|
||||
metrics: z
|
||||
.object({
|
||||
cpu: z.number(),
|
||||
memory: z.number(),
|
||||
tasksFailed: z.number(),
|
||||
tasksSucceeded: z.number(),
|
||||
responseTime: z.number(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
const PING_SCHEMA = z.object({ timestamp: z.number() });
|
||||
|
||||
const TASK_RESULT_SCHEMA = z.unknown();
|
||||
|
||||
/**
|
||||
* `WorkerCommunicator` over `fetch`. The `fetch` implementation is
|
||||
* injectable so tests can drive deterministic responses; production
|
||||
* defaults to `globalThis.fetch`.
|
||||
*/
|
||||
export class HttpWorkerCommunicator extends AbstractWorkerCommunicator {
|
||||
readonly #fetch: FetchLike;
|
||||
|
||||
constructor(
|
||||
options: HttpWorkerCommunicatorOptions = {},
|
||||
deps: HttpWorkerCommunicatorDeps = {}
|
||||
) {
|
||||
super(options, deps);
|
||||
this.#fetch =
|
||||
deps.fetch ?? (globalThis.fetch.bind(globalThis) as FetchLike);
|
||||
}
|
||||
|
||||
override async sendTask<I, O>(
|
||||
endpoint: string,
|
||||
task: DistributedTask<I, O>,
|
||||
input: I,
|
||||
signal?: AbortSignal
|
||||
): Promise<O> {
|
||||
const url = buildUrl(endpoint, "/execute");
|
||||
const payload = {
|
||||
taskId: task.id,
|
||||
taskMetadata: task.getMetadata(),
|
||||
input,
|
||||
};
|
||||
return this.withTimeout(
|
||||
"sendTask",
|
||||
this.#makeRequest<O>(
|
||||
url,
|
||||
"POST",
|
||||
TASK_RESULT_SCHEMA as z.ZodType<O>,
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override async checkStatus(
|
||||
endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerStatusValue> {
|
||||
const url = buildUrl(endpoint, "/status");
|
||||
return this.withTimeout(
|
||||
"checkStatus",
|
||||
this.#makeRequest(url, "GET", STATUS_SCHEMA, undefined, signal)
|
||||
);
|
||||
}
|
||||
|
||||
override async checkHealth(
|
||||
endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerHealth> {
|
||||
const url = buildUrl(endpoint, "/health");
|
||||
return this.withTimeout(
|
||||
"checkHealth",
|
||||
this.#makeRequest(
|
||||
url,
|
||||
"GET",
|
||||
HEALTH_SCHEMA as z.ZodType<WorkerHealth>,
|
||||
undefined,
|
||||
signal
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override async ping(
|
||||
endpoint: string,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
const url = buildUrl(endpoint, "/ping");
|
||||
const start = this.clock();
|
||||
await this.withTimeout(
|
||||
"ping",
|
||||
this.#makeRequest(url, "GET", PING_SCHEMA, undefined, signal),
|
||||
timeoutMs
|
||||
);
|
||||
return this.clock() - start;
|
||||
}
|
||||
|
||||
override getProtocol(): string {
|
||||
return "http";
|
||||
}
|
||||
|
||||
async #makeRequest<T>(
|
||||
url: string,
|
||||
method: "GET" | "POST",
|
||||
dataSchema: z.ZodType<T>,
|
||||
body: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...this.headers,
|
||||
},
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = this.serialize(body);
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
init.signal = signal;
|
||||
}
|
||||
const start = this.clock();
|
||||
try {
|
||||
const response = await this.#fetch(url, init);
|
||||
const json = await response.json();
|
||||
const envelope =
|
||||
workerApiResponseSchema(dataSchema).safeParse(json);
|
||||
if (!envelope.success) {
|
||||
recordHttpRequestOutcome("failure");
|
||||
recordHttpRequestDuration(this.clock() - start, "failure");
|
||||
throw new HttpResponseMalformedError({
|
||||
url,
|
||||
cause: envelope.error,
|
||||
});
|
||||
}
|
||||
if (!envelope.data.success) {
|
||||
recordHttpRequestOutcome("failure");
|
||||
recordHttpRequestDuration(this.clock() - start, "failure");
|
||||
throw new HttpProtocolError({
|
||||
url,
|
||||
status: response.status,
|
||||
upstreamCode: envelope.data.error.code,
|
||||
upstreamMessage: envelope.data.error.message,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
recordHttpRequestOutcome("failure");
|
||||
recordHttpRequestDuration(this.clock() - start, "failure");
|
||||
throw new HttpProtocolError({
|
||||
url,
|
||||
status: response.status,
|
||||
upstreamCode: "HTTP_NON_2XX",
|
||||
upstreamMessage: response.statusText,
|
||||
});
|
||||
}
|
||||
recordHttpRequestOutcome("success");
|
||||
recordHttpRequestDuration(this.clock() - start, "success");
|
||||
return envelope.data.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof HttpResponseMalformedError ||
|
||||
error instanceof HttpProtocolError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new HttpRequestFailedError({ url, cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(endpoint: string, path: string): string {
|
||||
const base = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `QueueDistributionHttpError` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`QueueDistributionHttpError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-http`. Each subclass carries a stable code from the registry produced by `defineCodes("HTTP", [...])`. Consumers programmatically match on `error.code` to distinguish network failures from upstream protocol errors from timeouts — the registry is exhaustive and every code is thrown by at least one production code path.
|
||||
|
||||
## Features
|
||||
|
||||
- 6 stable codes — `REQUEST_FAILED`, `PROTOCOL_ERROR`, `TIMEOUT`, `RESPONSE_MALFORMED`, `CONNECT_FAILED`, `UNREACHABLE`.
|
||||
- One concrete subclass per code with a typed constructor signature.
|
||||
- Optional `cause` narrowed to `Error` instances; non-Error causes drop silently.
|
||||
- Compatible with `SuaveplanError` cause-chain traversal.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
HTTP_CODES,
|
||||
QueueDistributionHttpError,
|
||||
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
|
||||
|
||||
try {
|
||||
await communicator.sendTask("http://w", task, input);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof QueueDistributionHttpError &&
|
||||
error.code === HTTP_CODES.PROTOCOL_ERROR
|
||||
) {
|
||||
// Upstream worker reported a structured failure — surface it.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with `@suaveplan/error` cause-chain helpers to extract underlying network failures.
|
||||
|
||||
```ts
|
||||
import {
|
||||
HTTP_CODES,
|
||||
HttpRequestFailedError,
|
||||
QueueDistributionHttpError,
|
||||
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
|
||||
|
||||
function classify(error: unknown): "network" | "protocol" | "unknown" {
|
||||
if (!(error instanceof QueueDistributionHttpError)) {
|
||||
return "unknown";
|
||||
}
|
||||
if (error.code === HTTP_CODES.REQUEST_FAILED) {
|
||||
return "network";
|
||||
}
|
||||
if (
|
||||
error.code === HTTP_CODES.PROTOCOL_ERROR ||
|
||||
error.code === HTTP_CODES.RESPONSE_MALFORMED
|
||||
) {
|
||||
return "protocol";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const HTTP_CODES` — frozen registry of `HTTP.*` codes.
|
||||
- `type QueueDistributionHttpErrorCode` — union of every code.
|
||||
- `class QueueDistributionHttpError extends SuaveplanError<QueueDistributionHttpErrorCode>`.
|
||||
- `class HttpRequestFailedError({ url, cause? })`.
|
||||
- `class HttpProtocolError({ url, status, upstreamCode, upstreamMessage, cause? })`.
|
||||
- `class HttpTimeoutError({ operation, timeoutMs })`.
|
||||
- `class HttpResponseMalformedError({ url, cause? })`.
|
||||
- `class HttpConnectFailedError({ endpoint, attempts, cause? })`.
|
||||
- `class HttpUnreachableError({ endpoint, cause? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`HttpProtocolError` carries the upstream `code` and `message` in its formatted message so logs and dashboards can group failures by the remote worker's own error vocabulary without parsing JSON. `HttpUnreachableError` exists as a documented "this is the failure-class" signal — the error is allocated and discarded inside `RemoteWorker.#handleCommunicationError` because the worker's reaction (markUnhealthy + reconnect) is the actual recovery; the error class is here so future call sites (e.g. observability dashboards) can match on the registered code.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HTTP_CODES,
|
||||
HttpConnectFailedError,
|
||||
HttpProtocolError,
|
||||
HttpRequestFailedError,
|
||||
HttpResponseMalformedError,
|
||||
HttpTimeoutError,
|
||||
HttpUnreachableError,
|
||||
QueueDistributionHttpError,
|
||||
} from "./queue-distribution-http-error.js";
|
||||
|
||||
describe("QueueDistributionHttpError registry", () => {
|
||||
it("registers exactly the expected codes", { timeout: 5000 }, () => {
|
||||
expect(Object.values(HTTP_CODES).sort()).toEqual(
|
||||
[
|
||||
"HTTP.REQUEST_FAILED",
|
||||
"HTTP.PROTOCOL_ERROR",
|
||||
"HTTP.TIMEOUT",
|
||||
"HTTP.RESPONSE_MALFORMED",
|
||||
"HTTP.CONNECT_FAILED",
|
||||
"HTTP.UNREACHABLE",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every error subclass carries the right code", { timeout: 5000 }, () => {
|
||||
const samples: { err: QueueDistributionHttpError; code: string }[] = [
|
||||
{
|
||||
err: new HttpRequestFailedError({ url: "x" }),
|
||||
code: HTTP_CODES.REQUEST_FAILED,
|
||||
},
|
||||
{
|
||||
err: new HttpProtocolError({
|
||||
url: "x",
|
||||
status: 500,
|
||||
upstreamCode: "U",
|
||||
upstreamMessage: "M",
|
||||
}),
|
||||
code: HTTP_CODES.PROTOCOL_ERROR,
|
||||
},
|
||||
{
|
||||
err: new HttpTimeoutError({
|
||||
operation: "ping",
|
||||
timeoutMs: 100,
|
||||
}),
|
||||
code: HTTP_CODES.TIMEOUT,
|
||||
},
|
||||
{
|
||||
err: new HttpResponseMalformedError({ url: "x" }),
|
||||
code: HTTP_CODES.RESPONSE_MALFORMED,
|
||||
},
|
||||
{
|
||||
err: new HttpConnectFailedError({ endpoint: "x", attempts: 3 }),
|
||||
code: HTTP_CODES.CONNECT_FAILED,
|
||||
},
|
||||
{
|
||||
err: new HttpUnreachableError({ endpoint: "x" }),
|
||||
code: HTTP_CODES.UNREACHABLE,
|
||||
},
|
||||
];
|
||||
for (const { err, code } of samples) {
|
||||
expect(err).toBeInstanceOf(QueueDistributionHttpError);
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves Error causes and drops non-Error causes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("upstream");
|
||||
expect(new HttpRequestFailedError({ url: "x", cause }).cause).toBe(
|
||||
cause
|
||||
);
|
||||
expect(
|
||||
new HttpRequestFailedError({ url: "x", cause: "string" }).cause
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
new HttpProtocolError({
|
||||
url: "x",
|
||||
status: 500,
|
||||
upstreamCode: "U",
|
||||
upstreamMessage: "M",
|
||||
cause,
|
||||
}).cause
|
||||
).toBe(cause);
|
||||
expect(new HttpResponseMalformedError({ url: "x", cause }).cause).toBe(
|
||||
cause
|
||||
);
|
||||
expect(
|
||||
new HttpConnectFailedError({ endpoint: "x", attempts: 3, cause })
|
||||
.cause
|
||||
).toBe(cause);
|
||||
expect(new HttpUnreachableError({ endpoint: "x", cause }).cause).toBe(
|
||||
cause
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/** Stable error-code registry for `@suaveplan/queue-distribution-http`. */
|
||||
export const HTTP_CODES = defineCodes("HTTP", [
|
||||
"REQUEST_FAILED",
|
||||
"PROTOCOL_ERROR",
|
||||
"TIMEOUT",
|
||||
"RESPONSE_MALFORMED",
|
||||
"CONNECT_FAILED",
|
||||
"UNREACHABLE",
|
||||
] as const);
|
||||
|
||||
export type QueueDistributionHttpErrorCode =
|
||||
(typeof HTTP_CODES)[keyof typeof HTTP_CODES];
|
||||
|
||||
export class QueueDistributionHttpError extends SuaveplanError<QueueDistributionHttpErrorCode> {}
|
||||
|
||||
function asErrorCause(cause: unknown): Error | undefined {
|
||||
return cause instanceof Error ? cause : undefined;
|
||||
}
|
||||
|
||||
/** Thrown when the network-layer `fetch` rejects (DNS, TCP, TLS, AbortError, etc.). */
|
||||
export class HttpRequestFailedError extends QueueDistributionHttpError {
|
||||
constructor(options: { url: string; cause?: unknown }) {
|
||||
super(
|
||||
HTTP_CODES.REQUEST_FAILED,
|
||||
`HTTP request to ${options.url} failed`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the wire-envelope reports success === false or HTTP status is non-2xx. */
|
||||
export class HttpProtocolError extends QueueDistributionHttpError {
|
||||
constructor(options: {
|
||||
url: string;
|
||||
status: number;
|
||||
upstreamCode: string;
|
||||
upstreamMessage: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(
|
||||
HTTP_CODES.PROTOCOL_ERROR,
|
||||
`HTTP ${options.status} from ${options.url}: ${options.upstreamCode} — ${options.upstreamMessage}`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an HTTP operation exceeds its configured deadline. */
|
||||
export class HttpTimeoutError extends QueueDistributionHttpError {
|
||||
constructor(options: { operation: string; timeoutMs: number }) {
|
||||
super(
|
||||
HTTP_CODES.TIMEOUT,
|
||||
`HTTP ${options.operation} exceeded ${options.timeoutMs}ms deadline`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the response body cannot be parsed against the wire-envelope Zod schema. */
|
||||
export class HttpResponseMalformedError extends QueueDistributionHttpError {
|
||||
constructor(options: { url: string; cause?: unknown }) {
|
||||
super(
|
||||
HTTP_CODES.RESPONSE_MALFORMED,
|
||||
`HTTP response from ${options.url} did not match the expected envelope`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when `RemoteWorker` exhausts its connect-attempt budget. */
|
||||
export class HttpConnectFailedError extends QueueDistributionHttpError {
|
||||
constructor(options: {
|
||||
endpoint: string;
|
||||
attempts: number;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(
|
||||
HTTP_CODES.CONNECT_FAILED,
|
||||
`Failed to connect to ${options.endpoint} after ${options.attempts} attempt(s)`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Emitted when a periodic ping shows the remote worker is unreachable. */
|
||||
export class HttpUnreachableError extends QueueDistributionHttpError {
|
||||
constructor(options: { endpoint: string; cause?: unknown }) {
|
||||
super(
|
||||
HTTP_CODES.UNREACHABLE,
|
||||
`Remote worker at ${options.endpoint} is unreachable`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export {
|
||||
AbstractWorkerCommunicator,
|
||||
type AbstractWorkerCommunicatorDeps,
|
||||
type WorkerCommunicator,
|
||||
} from "./communicator/base/base.js";
|
||||
export {
|
||||
type FetchLike,
|
||||
HttpWorkerCommunicator,
|
||||
type HttpWorkerCommunicatorDeps,
|
||||
type HttpWorkerCommunicatorOptions,
|
||||
workerApiResponseSchema,
|
||||
} from "./communicator/http/http-communicator.js";
|
||||
export {
|
||||
HTTP_CODES,
|
||||
HttpConnectFailedError,
|
||||
HttpProtocolError,
|
||||
HttpRequestFailedError,
|
||||
HttpResponseMalformedError,
|
||||
HttpTimeoutError,
|
||||
HttpUnreachableError,
|
||||
QueueDistributionHttpError,
|
||||
type QueueDistributionHttpErrorCode,
|
||||
} from "./errors/queue-distribution-http-error.js";
|
||||
export {
|
||||
getConnectTracer,
|
||||
getSendTaskTracer,
|
||||
recordConnectAttempt,
|
||||
recordHttpRequestDuration,
|
||||
recordHttpRequestOutcome,
|
||||
resetInstrumentCache,
|
||||
} from "./observability/instruments.js";
|
||||
export {
|
||||
RemoteWorker,
|
||||
type RemoteWorkerOptions,
|
||||
} from "./worker/remote/remote.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# HTTP plugin OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, and tracer emitted by `@suaveplan/queue-distribution-http`. Instruments are registered under the meter / tracer name `"@suaveplan/queue-distribution-http"` so consumers configuring an SDK can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` so tests can swap providers between assertions.
|
||||
|
||||
## Features
|
||||
|
||||
- Counters: `queue_dist_http.request_total{outcome}`, `queue_dist_http.connect.attempts_total{outcome}`.
|
||||
- Histogram: `queue_dist_http.request.duration_ms{outcome}`.
|
||||
- Tracers: `queue_dist_http.sendTask`, `queue_dist_http.connect`.
|
||||
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Production code calls the instruments automatically. Consumers add a meter / tracer provider at startup:
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
In tests, install an in-memory exporter, drive the system, then assert directly on the captured metrics.
|
||||
|
||||
```ts
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { resetInstrumentCache } from "@suaveplan/queue-distribution-http/observability/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
resetInstrumentCache();
|
||||
// … run system under test, then await reader.forceFlush() …
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function resetInstrumentCache(): void`.
|
||||
- `function recordHttpRequestOutcome(outcome: "success" | "failure"): void`.
|
||||
- `function recordHttpRequestDuration(durationMs, outcome): void`.
|
||||
- `function recordConnectAttempt(outcome: "success" | "failure" | "exhausted"): void`.
|
||||
- `function getSendTaskTracer(): Tracer`, `function getConnectTracer(): Tracer`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments share a single meter handle so the per-call overhead in the hot path is a single map lookup. `resetInstrumentCache()` is wired exclusively for tests; production code never calls it because the underlying global provider is set once at startup. The `outcome` attribute is constrained to the three documented values for connect attempts and two for requests — keeping the cardinality bounded for backend dashboards.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getConnectTracer,
|
||||
getSendTaskTracer,
|
||||
recordConnectAttempt,
|
||||
recordHttpRequestDuration,
|
||||
recordHttpRequestOutcome,
|
||||
resetInstrumentCache,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("HTTP-plugin observability", () => {
|
||||
it("every recorder is callable without a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => {
|
||||
recordHttpRequestOutcome("success");
|
||||
recordHttpRequestOutcome("failure");
|
||||
recordHttpRequestDuration(100, "success");
|
||||
recordHttpRequestDuration(150, "failure");
|
||||
recordConnectAttempt("success");
|
||||
recordConnectAttempt("failure");
|
||||
recordConnectAttempt("exhausted");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("tracers are cached across calls", { timeout: 5000 }, () => {
|
||||
resetInstrumentCache();
|
||||
const t1 = getSendTaskTracer();
|
||||
const t2 = getSendTaskTracer();
|
||||
expect(t1).toBe(t2);
|
||||
expect(typeof getConnectTracer().startSpan).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { metrics, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/queue-distribution-http";
|
||||
const TRACER_NAME = "@suaveplan/queue-distribution-http";
|
||||
|
||||
let cachedMeter: ReturnType<typeof metrics.getMeter> | undefined;
|
||||
let cachedTracer: Tracer | undefined;
|
||||
|
||||
function getMeter(): ReturnType<typeof metrics.getMeter> {
|
||||
if (cachedMeter === undefined) {
|
||||
cachedMeter = metrics.getMeter(METER_NAME);
|
||||
}
|
||||
return cachedMeter;
|
||||
}
|
||||
|
||||
function getTracer(): Tracer {
|
||||
if (cachedTracer === undefined) {
|
||||
cachedTracer = trace.getTracer(TRACER_NAME);
|
||||
}
|
||||
return cachedTracer;
|
||||
}
|
||||
|
||||
/** Reset cached handles. Tests call this after swapping a global provider. */
|
||||
export function resetInstrumentCache(): void {
|
||||
cachedMeter = undefined;
|
||||
cachedTracer = undefined;
|
||||
}
|
||||
|
||||
export function recordHttpRequestOutcome(outcome: "success" | "failure"): void {
|
||||
getMeter()
|
||||
.createCounter("queue_dist_http.request_total")
|
||||
.add(1, { outcome });
|
||||
}
|
||||
|
||||
export function recordHttpRequestDuration(
|
||||
durationMs: number,
|
||||
outcome: "success" | "failure"
|
||||
): void {
|
||||
getMeter()
|
||||
.createHistogram("queue_dist_http.request.duration_ms")
|
||||
.record(durationMs, { outcome });
|
||||
}
|
||||
|
||||
export function recordConnectAttempt(
|
||||
outcome: "success" | "failure" | "exhausted"
|
||||
): void {
|
||||
getMeter()
|
||||
.createCounter("queue_dist_http.connect.attempts_total")
|
||||
.add(1, { outcome });
|
||||
}
|
||||
|
||||
export function getSendTaskTracer(): Tracer {
|
||||
return getTracer();
|
||||
}
|
||||
|
||||
export function getConnectTracer(): Tracer {
|
||||
return getTracer();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# `RemoteWorker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RemoteWorker` extends `AbstractWorker` and delegates execution to a remote endpoint via an injected `WorkerCommunicator`. It manages the connect / reconnect lifecycle (with `@suaveplan/retry`-style exponential backoff routed through `sleep`), runs a periodic health-check loop, and reacts to communication failures by pinging and either reporting the worker still-reachable or marking it `UNHEALTHY`. The connect loop is bounded — no recursion — and every retry attempt is captured in the OTel `queue_dist_http.connect.attempts_total` counter.
|
||||
|
||||
## Features
|
||||
|
||||
- `start()` calls `#connect` (exponential backoff between attempts) then starts a `setInterval` health-check loop.
|
||||
- `stop(force, signal?)` halts the loop, disconnects the communicator, and flips `#isConnected` to `false`.
|
||||
- `doExecuteTask` refuses when not connected (`HTTP.CONNECT_FAILED`).
|
||||
- Communication-error recovery: ping with a 5-second timeout; reachable → log + propagate; unreachable → `markUnhealthy("Unreachable")` + `#isConnected = false`.
|
||||
- `getHealth()` proxies to the remote and refreshes `lastHealthCheck` / `lastHealthStatus`; on failure returns a fallback unhealthy payload.
|
||||
- Configurable `healthCheckIntervalMs`, `maxConnectionAttempts`, `initialBackoffMs`, `maxBackoffMs`.
|
||||
- `getCapabilities()` advertises `{ isRemote: true, protocol, endpoint }` so strategies can route work.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http";
|
||||
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
|
||||
|
||||
const communicator = new HttpWorkerCommunicator();
|
||||
const worker = new RemoteWorker("https://worker.example", communicator, {
|
||||
healthCheckIntervalMs: 15_000,
|
||||
maxConnectionAttempts: 5,
|
||||
});
|
||||
|
||||
await worker.start();
|
||||
const result = await worker.executeTask(task, input);
|
||||
await worker.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For deterministic property-tests, inject a custom communicator and watch connect retries with a fake clock.
|
||||
|
||||
```ts
|
||||
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
|
||||
import type { WorkerCommunicator } from "@suaveplan/queue-distribution-http";
|
||||
|
||||
let connectAttempts = 0;
|
||||
const flakyCommunicator: WorkerCommunicator = {
|
||||
sendTask: async (_e, _t, input) => input as never,
|
||||
connect: async () => {
|
||||
connectAttempts += 1;
|
||||
if (connectAttempts < 3) throw new Error("refused");
|
||||
},
|
||||
checkStatus: async () => "ONLINE",
|
||||
checkHealth: async () => ({
|
||||
isHealthy: true,
|
||||
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
|
||||
}),
|
||||
disconnect: async () => undefined,
|
||||
ping: async () => 5,
|
||||
getProtocol: () => "http",
|
||||
};
|
||||
|
||||
const worker = new RemoteWorker("https://worker", flakyCommunicator, {
|
||||
maxConnectionAttempts: 5,
|
||||
initialBackoffMs: 0,
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
console.log(connectAttempts); // 3
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RemoteWorker extends AbstractWorker`.
|
||||
- `constructor(endpoint, communicator, options?: RemoteWorkerOptions, deps?: AbstractWorkerDeps)`.
|
||||
- `interface RemoteWorkerOptions extends AbstractWorkerOptions` — `healthCheckIntervalMs?`, `maxConnectionAttempts?`, `initialBackoffMs?`, `maxBackoffMs?`.
|
||||
- `getEndpoint(): string`, `getCommunicator(): WorkerCommunicator`, `isConnected(): boolean`.
|
||||
- `getHealth(signal?): Promise<WorkerHealth>` — overrides the abstract default.
|
||||
- Protected: `doExecuteTask`, `doStart`, `doStop`, `getType()`, `getCapabilities()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The connect loop iterates up to `maxConnectionAttempts` times, doubling the delay each iteration (capped at `maxBackoffMs`). On every retry, the loop also re-checks the remote status — so a worker that responds OK on the third attempt connects without further delay. Communication errors during sendTask are NOT silently swallowed: the worker rethrows so the calling `WorkerManager` can retry against a different worker via its load-balancing strategy. The ping-based reachability probe runs in parallel with the rethrow so the worker's `status` is up-to-date by the time the next strategy invocation observes the pool.
|
||||
@@ -0,0 +1,366 @@
|
||||
import type {
|
||||
DistributedTask,
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { WorkerCommunicator } from "../../communicator/base/base.js";
|
||||
import { HttpConnectFailedError } from "../../errors/queue-distribution-http-error.js";
|
||||
import { RemoteWorker } from "./remote.js";
|
||||
|
||||
function fakeCommunicator(
|
||||
overrides: Partial<WorkerCommunicator> = {}
|
||||
): WorkerCommunicator {
|
||||
return {
|
||||
sendTask: overrides.sendTask ?? (async () => undefined as never),
|
||||
checkStatus:
|
||||
overrides.checkStatus ??
|
||||
(async () => "ONLINE" as WorkerStatusValue),
|
||||
checkHealth:
|
||||
overrides.checkHealth ??
|
||||
(async () => ({
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
})),
|
||||
connect: overrides.connect ?? (async () => undefined),
|
||||
disconnect: overrides.disconnect ?? (async () => undefined),
|
||||
ping: overrides.ping ?? (async () => 0),
|
||||
getProtocol: overrides.getProtocol ?? (() => "http"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask<I, O>(): DistributedTask<I, O> {
|
||||
let status = "PENDING" as string;
|
||||
return {
|
||||
id: "t",
|
||||
execute: async () => undefined as never,
|
||||
getStatus: () => status as never,
|
||||
setStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoteWorker", () => {
|
||||
it("start resolves with ONLINE on first connect", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
expect(worker.isConnected()).toBe(true);
|
||||
expect(worker.status).toBe("ONLINE");
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("getEndpoint and getCommunicator expose constructor inputs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const comm = fakeCommunicator();
|
||||
const worker = new RemoteWorker("http://y", comm);
|
||||
expect(worker.getEndpoint()).toBe("http://y");
|
||||
expect(worker.getCommunicator()).toBe(comm);
|
||||
expect(worker.getInfo().capabilities).toEqual({
|
||||
isRemote: true,
|
||||
protocol: "http",
|
||||
endpoint: "http://y",
|
||||
});
|
||||
expect(worker.getInfo().type).toBe("remote");
|
||||
});
|
||||
|
||||
it("doExecuteTask throws HTTP.CONNECT_FAILED when not connected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
// Don't start — isConnected stays false. status is OFFLINE so executeTask
|
||||
// throws WORKER_NOT_AVAILABLE before reaching doExecuteTask. Drive the
|
||||
// status to ONLINE manually so doExecuteTask is the gating layer.
|
||||
(worker as unknown as { status: string }).status = "ONLINE";
|
||||
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
|
||||
HttpConnectFailedError
|
||||
);
|
||||
});
|
||||
|
||||
it("communicator throw during sendTask routes through ping for diagnosis", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const comm = fakeCommunicator({
|
||||
sendTask: async () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
ping: async () => 5, // reachable
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
|
||||
"transient"
|
||||
);
|
||||
// Worker still reachable; not marked UNHEALTHY.
|
||||
expect(worker.status).toBe("ONLINE");
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("ping failure during sendTask marks UNHEALTHY and disconnects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const comm = fakeCommunicator({
|
||||
sendTask: async () => {
|
||||
throw new Error("comm broken");
|
||||
},
|
||||
ping: async () => {
|
||||
throw new Error("unreachable");
|
||||
},
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
|
||||
"comm broken"
|
||||
);
|
||||
expect(worker.status).toBe("UNHEALTHY");
|
||||
expect(worker.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it("getHealth pass-through from communicator", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const remoteHealth: WorkerHealth = {
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 50,
|
||||
memory: 60,
|
||||
tasksFailed: 2,
|
||||
tasksSucceeded: 8,
|
||||
responseTime: 100,
|
||||
},
|
||||
};
|
||||
const comm = fakeCommunicator({
|
||||
checkHealth: async () => remoteHealth,
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm);
|
||||
expect(await worker.getHealth()).toEqual(remoteHealth);
|
||||
});
|
||||
|
||||
it("getHealth fallback shape when communicator throws and ping fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const comm = fakeCommunicator({
|
||||
checkHealth: async () => {
|
||||
throw new Error("comm broken");
|
||||
},
|
||||
ping: async () => {
|
||||
throw new Error("unreachable");
|
||||
},
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm);
|
||||
const health = await worker.getHealth();
|
||||
expect(health.isHealthy).toBe(false);
|
||||
expect(worker.status).toBe("UNHEALTHY");
|
||||
});
|
||||
|
||||
it("exhausts retries and throws HTTP.CONNECT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let attempts = 0;
|
||||
const comm = fakeCommunicator({
|
||||
connect: async () => {
|
||||
attempts += 1;
|
||||
throw new Error("refused");
|
||||
},
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
maxConnectionAttempts: 3,
|
||||
initialBackoffMs: 0,
|
||||
maxBackoffMs: 0,
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await expect(worker.start()).rejects.toThrow(HttpConnectFailedError);
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it("retries when checkStatus reports a non-online state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let checks = 0;
|
||||
const comm = fakeCommunicator({
|
||||
checkStatus: async () => {
|
||||
checks += 1;
|
||||
return checks < 2 ? "OFFLINE" : "ONLINE";
|
||||
},
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
maxConnectionAttempts: 3,
|
||||
initialBackoffMs: 0,
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
expect(worker.isConnected()).toBe(true);
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("BUSY remote status is also accepted at connect time", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const comm = fakeCommunicator({ checkStatus: async () => "BUSY" });
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
expect(worker.isConnected()).toBe(true);
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("stop swallows disconnect errors", { timeout: 5000 }, async () => {
|
||||
const comm = fakeCommunicator({
|
||||
disconnect: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
await expect(worker.stop(true)).resolves.toBeUndefined();
|
||||
expect(worker.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it("health-check interval is non-recursive and idempotent on stop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
// Calling stop twice is safe.
|
||||
await worker.stop(true);
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("periodic health cycle promotes UNHEALTHY when remote returns unhealthy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const comm = fakeCommunicator({
|
||||
checkHealth: async () => ({
|
||||
isHealthy: false,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
}),
|
||||
ping: async () => 5,
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
await worker.start();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(worker.status).toBe("UNHEALTHY");
|
||||
await worker.stop(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("periodic health cycle recovers UNHEALTHY worker when remote returns healthy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
await worker.start();
|
||||
(worker as unknown as { status: string }).status = "UNHEALTHY";
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(worker.status).toBe("ONLINE");
|
||||
await worker.stop(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("periodic health cycle swallows thrown errors from getHealth", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let calls = 0;
|
||||
const comm = fakeCommunicator({
|
||||
checkHealth: async () => {
|
||||
calls += 1;
|
||||
if (calls > 1) {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return {
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
ping: async () => 5,
|
||||
});
|
||||
const worker = new RemoteWorker("http://x", comm, {
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
await worker.start();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
// Should not throw — the catch in #runHealthCycle swallows it.
|
||||
await worker.stop(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers to BUSY status when activeTasks > 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
await worker.start();
|
||||
(worker as unknown as { status: string }).status = "UNHEALTHY";
|
||||
(worker as unknown as { activeTasks: number }).activeTasks = 2;
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(worker.status).toBe("BUSY");
|
||||
await worker.stop(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("start path can be called twice safely (loop is idempotent)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
await worker.start();
|
||||
await worker.start(); // AbstractWorker.start short-circuits when already ONLINE
|
||||
await worker.stop(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
AbstractWorker,
|
||||
type AbstractWorkerDeps,
|
||||
type AbstractWorkerOptions,
|
||||
type DistributedTask,
|
||||
type WorkerHealth,
|
||||
WorkerStatus,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import { sleep } from "@suaveplan/retry";
|
||||
import type { WorkerCommunicator } from "../../communicator/base/base.js";
|
||||
import {
|
||||
HttpConnectFailedError,
|
||||
HttpUnreachableError,
|
||||
} from "../../errors/queue-distribution-http-error.js";
|
||||
import { recordConnectAttempt } from "../../observability/instruments.js";
|
||||
|
||||
export interface RemoteWorkerOptions extends AbstractWorkerOptions {
|
||||
healthCheckIntervalMs?: number;
|
||||
maxConnectionAttempts?: number;
|
||||
initialBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HEALTH_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_INITIAL_BACKOFF_MS = 1_000;
|
||||
const DEFAULT_MAX_BACKOFF_MS = 10_000;
|
||||
const PING_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Worker that delegates execution to a remote endpoint via a
|
||||
* `WorkerCommunicator`. Connect retries use exponential backoff routed
|
||||
* through `@suaveplan/retry`'s `sleep`. Periodic health checks drive
|
||||
* UNHEALTHY transitions; a failed ping during normal operation schedules a
|
||||
* non-blocking reconnect.
|
||||
*/
|
||||
export class RemoteWorker extends AbstractWorker {
|
||||
readonly #endpoint: string;
|
||||
readonly #communicator: WorkerCommunicator;
|
||||
readonly #healthCheckIntervalMs: number;
|
||||
readonly #maxConnectionAttempts: number;
|
||||
readonly #initialBackoffMs: number;
|
||||
readonly #maxBackoffMs: number;
|
||||
#isConnected = false;
|
||||
#healthCheckHandle: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
communicator: WorkerCommunicator,
|
||||
options: RemoteWorkerOptions = {},
|
||||
deps: AbstractWorkerDeps = {}
|
||||
) {
|
||||
super(options, deps);
|
||||
this.#endpoint = endpoint;
|
||||
this.#communicator = communicator;
|
||||
this.#healthCheckIntervalMs =
|
||||
options.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
|
||||
this.#maxConnectionAttempts =
|
||||
options.maxConnectionAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
this.#initialBackoffMs =
|
||||
options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
||||
this.#maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
||||
}
|
||||
|
||||
getEndpoint(): string {
|
||||
return this.#endpoint;
|
||||
}
|
||||
|
||||
getCommunicator(): WorkerCommunicator {
|
||||
return this.#communicator;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.#isConnected;
|
||||
}
|
||||
|
||||
protected async doExecuteTask<I, O>(
|
||||
task: DistributedTask<I, O>,
|
||||
input: I
|
||||
): Promise<O> {
|
||||
if (!this.#isConnected) {
|
||||
throw new HttpConnectFailedError({
|
||||
endpoint: this.#endpoint,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await this.#communicator.sendTask(
|
||||
this.#endpoint,
|
||||
task,
|
||||
input
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
await this.#handleCommunicationError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async doStart(signal?: AbortSignal): Promise<void> {
|
||||
await this.#connect(signal);
|
||||
this.#startHealthLoop();
|
||||
}
|
||||
|
||||
protected override async doStop(
|
||||
_force: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#stopHealthLoop();
|
||||
try {
|
||||
await this.#communicator.disconnect(this.#endpoint, signal);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(`Disconnect from ${this.#endpoint} failed`, {
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
this.#isConnected = false;
|
||||
}
|
||||
|
||||
override async getHealth(signal?: AbortSignal): Promise<WorkerHealth> {
|
||||
try {
|
||||
const remoteHealth = await this.#communicator.checkHealth(
|
||||
this.#endpoint,
|
||||
signal
|
||||
);
|
||||
(this as unknown as { lastHealthCheck: Date }).lastHealthCheck =
|
||||
new Date((this as unknown as { clock: () => number }).clock());
|
||||
(
|
||||
this as unknown as { lastHealthStatus: WorkerHealth }
|
||||
).lastHealthStatus = remoteHealth;
|
||||
return remoteHealth;
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(`getHealth failed for ${this.#endpoint}`, {
|
||||
error: String(error),
|
||||
});
|
||||
await this.#handleCommunicationError(error);
|
||||
return {
|
||||
isHealthy: false,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected getType(): string {
|
||||
return "remote";
|
||||
}
|
||||
|
||||
protected override getCapabilities(): Readonly<Record<string, unknown>> {
|
||||
return {
|
||||
isRemote: true,
|
||||
protocol: this.#communicator.getProtocol(),
|
||||
endpoint: this.#endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
async #connect(signal?: AbortSignal): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= this.#maxConnectionAttempts;
|
||||
attempt += 1
|
||||
) {
|
||||
try {
|
||||
await this.#communicator.connect(this.#endpoint, signal);
|
||||
const status = await this.#communicator.checkStatus(
|
||||
this.#endpoint,
|
||||
signal
|
||||
);
|
||||
if (
|
||||
status === WorkerStatus.ONLINE ||
|
||||
status === WorkerStatus.BUSY
|
||||
) {
|
||||
this.#isConnected = true;
|
||||
recordConnectAttempt("success");
|
||||
this.logger.info(`Connected to ${this.#endpoint}`);
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`Remote reports status ${status}`);
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
recordConnectAttempt("failure");
|
||||
}
|
||||
if (attempt < this.#maxConnectionAttempts) {
|
||||
const delay = Math.min(
|
||||
this.#initialBackoffMs * 2 ** (attempt - 1),
|
||||
this.#maxBackoffMs
|
||||
);
|
||||
await sleep(delay, signal);
|
||||
}
|
||||
}
|
||||
recordConnectAttempt("exhausted");
|
||||
throw new HttpConnectFailedError({
|
||||
endpoint: this.#endpoint,
|
||||
attempts: this.#maxConnectionAttempts,
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
|
||||
#startHealthLoop(): void {
|
||||
this.#healthCheckHandle = setInterval(() => {
|
||||
void this.#runHealthCycle();
|
||||
}, this.#healthCheckIntervalMs);
|
||||
}
|
||||
|
||||
#stopHealthLoop(): void {
|
||||
clearInterval(this.#healthCheckHandle);
|
||||
this.#healthCheckHandle = undefined;
|
||||
}
|
||||
|
||||
async #runHealthCycle(): Promise<void> {
|
||||
const health = await this.getHealth();
|
||||
if (!health.isHealthy && this.status !== WorkerStatus.UNHEALTHY) {
|
||||
this.markUnhealthy("Failed health check");
|
||||
} else if (health.isHealthy && this.status === WorkerStatus.UNHEALTHY) {
|
||||
this.status =
|
||||
this.activeTasks > 0 ? WorkerStatus.BUSY : WorkerStatus.ONLINE;
|
||||
this.logger.info(`Recovered from UNHEALTHY: ${this.#endpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleCommunicationError(error: unknown): Promise<void> {
|
||||
try {
|
||||
await this.#communicator.ping(this.#endpoint, PING_TIMEOUT_MS);
|
||||
this.logger.warn(
|
||||
`Comm error but ${this.#endpoint} still reachable`,
|
||||
{
|
||||
error: String(error),
|
||||
}
|
||||
);
|
||||
} catch (pingError: unknown) {
|
||||
this.logger.error(`Remote ${this.#endpoint} unreachable`, {
|
||||
error: String(pingError),
|
||||
});
|
||||
this.markUnhealthy("Unreachable");
|
||||
this.#isConnected = false;
|
||||
void new HttpUnreachableError({
|
||||
endpoint: this.#endpoint,
|
||||
cause: pingError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/queue-distribution-http",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/queue-distribution",
|
||||
"@suaveplan/retry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user