predicate-claw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +76 -0
- package/.github/workflows/tests.yml +34 -0
- package/.markdownlint.yaml +5 -0
- package/.pre-commit-config.yaml +100 -0
- package/README.md +405 -0
- package/dist/src/adapter.d.ts +17 -0
- package/dist/src/adapter.js +36 -0
- package/dist/src/authority-client.d.ts +21 -0
- package/dist/src/authority-client.js +22 -0
- package/dist/src/circuit-breaker.d.ts +86 -0
- package/dist/src/circuit-breaker.js +174 -0
- package/dist/src/config.d.ts +8 -0
- package/dist/src/config.js +7 -0
- package/dist/src/control-plane-sync.d.ts +57 -0
- package/dist/src/control-plane-sync.js +99 -0
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.js +6 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/non-web-evidence.d.ts +46 -0
- package/dist/src/non-web-evidence.js +54 -0
- package/dist/src/openclaw-hooks.d.ts +27 -0
- package/dist/src/openclaw-hooks.js +54 -0
- package/dist/src/openclaw-plugin-api.d.ts +18 -0
- package/dist/src/openclaw-plugin-api.js +17 -0
- package/dist/src/provider.d.ts +48 -0
- package/dist/src/provider.js +154 -0
- package/dist/src/runtime-integration.d.ts +20 -0
- package/dist/src/runtime-integration.js +43 -0
- package/dist/src/web-evidence.d.ts +48 -0
- package/dist/src/web-evidence.js +49 -0
- package/dist/tests/adapter.test.d.ts +1 -0
- package/dist/tests/adapter.test.js +63 -0
- package/dist/tests/audit-event-e2e.test.d.ts +1 -0
- package/dist/tests/audit-event-e2e.test.js +209 -0
- package/dist/tests/authority-client.test.d.ts +1 -0
- package/dist/tests/authority-client.test.js +46 -0
- package/dist/tests/circuit-breaker.test.d.ts +1 -0
- package/dist/tests/circuit-breaker.test.js +200 -0
- package/dist/tests/control-plane-sync.test.d.ts +1 -0
- package/dist/tests/control-plane-sync.test.js +90 -0
- package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
- package/dist/tests/hack-vs-fix-demo.test.js +36 -0
- package/dist/tests/jwks-rotation.test.d.ts +1 -0
- package/dist/tests/jwks-rotation.test.js +232 -0
- package/dist/tests/load-latency.test.d.ts +1 -0
- package/dist/tests/load-latency.test.js +175 -0
- package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
- package/dist/tests/multi-tenant-isolation.test.js +146 -0
- package/dist/tests/non-web-evidence.test.d.ts +1 -0
- package/dist/tests/non-web-evidence.test.js +139 -0
- package/dist/tests/openclaw-hooks.test.d.ts +1 -0
- package/dist/tests/openclaw-hooks.test.js +38 -0
- package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
- package/dist/tests/openclaw-plugin-api.test.js +40 -0
- package/dist/tests/provider.test.d.ts +1 -0
- package/dist/tests/provider.test.js +190 -0
- package/dist/tests/runtime-integration.test.d.ts +1 -0
- package/dist/tests/runtime-integration.test.js +57 -0
- package/dist/tests/web-evidence.test.d.ts +1 -0
- package/dist/tests/web-evidence.test.js +89 -0
- package/docs/MIGRATION_GUIDE.md +405 -0
- package/docs/OPERATIONAL_RUNBOOK.md +389 -0
- package/docs/PRODUCTION_READINESS.md +134 -0
- package/docs/SLO_THRESHOLDS.md +193 -0
- package/examples/README.md +171 -0
- package/examples/docker/Dockerfile.test +16 -0
- package/examples/docker/README.md +48 -0
- package/examples/docker/docker-compose.test.yml +16 -0
- package/examples/non-web-evidence-demo.ts +184 -0
- package/examples/openclaw-plugin-smoke/index.ts +30 -0
- package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
- package/examples/openclaw-plugin-smoke/package.json +9 -0
- package/examples/openclaw_integration_example.py +41 -0
- package/examples/policy/README.md +165 -0
- package/examples/policy/approved-hosts.yaml +137 -0
- package/examples/policy/dev-workflow.yaml +206 -0
- package/examples/policy/policy.example.yaml +17 -0
- package/examples/policy/production-strict.yaml +97 -0
- package/examples/policy/sensitive-paths.yaml +114 -0
- package/examples/policy/source-trust.yaml +129 -0
- package/examples/policy/workspace-isolation.yaml +51 -0
- package/examples/runtime_registry_example.py +75 -0
- package/package.json +27 -0
- package/pyproject.toml +41 -0
- package/src/adapter.ts +45 -0
- package/src/authority-client.ts +50 -0
- package/src/circuit-breaker.ts +245 -0
- package/src/config.ts +15 -0
- package/src/control-plane-sync.ts +159 -0
- package/src/errors.ts +5 -0
- package/src/index.ts +12 -0
- package/src/non-web-evidence.ts +116 -0
- package/src/openclaw-hooks.ts +76 -0
- package/src/openclaw-plugin-api.ts +51 -0
- package/src/openclaw_predicate_provider/__init__.py +16 -0
- package/src/openclaw_predicate_provider/__main__.py +5 -0
- package/src/openclaw_predicate_provider/adapter.py +84 -0
- package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
- package/src/openclaw_predicate_provider/cli.py +160 -0
- package/src/openclaw_predicate_provider/config.py +42 -0
- package/src/openclaw_predicate_provider/errors.py +13 -0
- package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
- package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
- package/src/openclaw_predicate_provider/models.py +19 -0
- package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
- package/src/openclaw_predicate_provider/provider.py +69 -0
- package/src/openclaw_predicate_provider/py.typed +1 -0
- package/src/openclaw_predicate_provider/sidecar.py +59 -0
- package/src/provider.ts +220 -0
- package/src/runtime-integration.ts +68 -0
- package/src/web-evidence.ts +95 -0
- package/tests/adapter.test.ts +76 -0
- package/tests/audit-event-e2e.test.ts +258 -0
- package/tests/authority-client.test.ts +52 -0
- package/tests/circuit-breaker.test.ts +266 -0
- package/tests/conftest.py +9 -0
- package/tests/control-plane-sync.test.ts +114 -0
- package/tests/hack-vs-fix-demo.test.ts +44 -0
- package/tests/jwks-rotation.test.ts +274 -0
- package/tests/load-latency.test.ts +214 -0
- package/tests/multi-tenant-isolation.test.ts +183 -0
- package/tests/non-web-evidence.test.ts +168 -0
- package/tests/openclaw-hooks.test.ts +46 -0
- package/tests/openclaw-plugin-api.test.ts +50 -0
- package/tests/provider.test.ts +227 -0
- package/tests/runtime-integration.test.ts +70 -0
- package/tests/test_adapter.py +46 -0
- package/tests/test_cli.py +26 -0
- package/tests/test_openclaw_hooks.py +53 -0
- package/tests/test_provider.py +59 -0
- package/tests/test_runtime_integration.py +77 -0
- package/tests/test_sidecar_client.py +198 -0
- package/tests/web-evidence.test.ts +113 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Workspace Isolation Policy
|
|
2
|
+
# Restricts all file operations to a specific project directory.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# 1. Update WORKSPACE_ROOT to your project path
|
|
6
|
+
# 2. Add any additional allowed paths to the allowlist
|
|
7
|
+
|
|
8
|
+
version: 1
|
|
9
|
+
|
|
10
|
+
defaults:
|
|
11
|
+
effect: deny
|
|
12
|
+
|
|
13
|
+
rules:
|
|
14
|
+
# Allow reads within the workspace
|
|
15
|
+
- id: allow_workspace_reads
|
|
16
|
+
effect: allow
|
|
17
|
+
action: fs.read
|
|
18
|
+
resource: ./workspace/**
|
|
19
|
+
|
|
20
|
+
# Allow writes within the workspace
|
|
21
|
+
- id: allow_workspace_writes
|
|
22
|
+
effect: allow
|
|
23
|
+
action: fs.write
|
|
24
|
+
resource: ./workspace/**
|
|
25
|
+
|
|
26
|
+
# Allow reading package manifests (for dependency resolution)
|
|
27
|
+
- id: allow_package_manifests
|
|
28
|
+
effect: allow
|
|
29
|
+
action: fs.read
|
|
30
|
+
resource:
|
|
31
|
+
- ./package.json
|
|
32
|
+
- ./package-lock.json
|
|
33
|
+
- ./yarn.lock
|
|
34
|
+
- ./pnpm-lock.yaml
|
|
35
|
+
- ./Cargo.toml
|
|
36
|
+
- ./Cargo.lock
|
|
37
|
+
- ./go.mod
|
|
38
|
+
- ./go.sum
|
|
39
|
+
- ./requirements.txt
|
|
40
|
+
- ./pyproject.toml
|
|
41
|
+
|
|
42
|
+
# Deny all paths outside workspace (explicit for clarity)
|
|
43
|
+
- id: deny_outside_workspace
|
|
44
|
+
effect: deny
|
|
45
|
+
action: fs.*
|
|
46
|
+
resource: /**
|
|
47
|
+
|
|
48
|
+
metadata:
|
|
49
|
+
name: Workspace Isolation
|
|
50
|
+
description: Restrict file operations to project directory
|
|
51
|
+
version: 1.0.0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Example wiring for OpenClawRuntimeIntegrator with a mock registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openclaw_predicate_provider.integrations import OpenClawRuntimeIntegrator
|
|
9
|
+
from openclaw_predicate_provider.openclaw_hooks import HookEnvelope, OpenClawHooks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MockRegistry:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._handlers = {
|
|
15
|
+
"cmd.run": self._cmd_run,
|
|
16
|
+
"fs.readFile": self._fs_read,
|
|
17
|
+
"http.request": self._http_request,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async def _cmd_run(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
return {"tool": "cmd.run", "args": args}
|
|
22
|
+
|
|
23
|
+
async def _fs_read(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
24
|
+
return {"tool": "fs.readFile", "args": args}
|
|
25
|
+
|
|
26
|
+
async def _http_request(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
return {"tool": "http.request", "args": args}
|
|
28
|
+
|
|
29
|
+
def get(self, tool_name: str):
|
|
30
|
+
return self._handlers[tool_name]
|
|
31
|
+
|
|
32
|
+
def set(self, tool_name: str, handler) -> None:
|
|
33
|
+
self._handlers[tool_name] = handler
|
|
34
|
+
|
|
35
|
+
async def invoke(self, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
return await self._handlers[tool_name](args)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_context(tool_name: str, args: dict[str, Any]) -> HookEnvelope:
|
|
40
|
+
return HookEnvelope(
|
|
41
|
+
tool_name=tool_name,
|
|
42
|
+
args=args,
|
|
43
|
+
session_id="demo-session",
|
|
44
|
+
source="trusted_ui",
|
|
45
|
+
tenant_id="demo-tenant",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def main() -> None:
|
|
50
|
+
# In real OpenClaw integration this uses GuardedProvider+ToolAdapter hooks.
|
|
51
|
+
class PassThroughHooks:
|
|
52
|
+
async def on_cmd_run(self, envelope, execute):
|
|
53
|
+
return await execute(envelope.args)
|
|
54
|
+
|
|
55
|
+
async def on_fs_read(self, envelope, execute):
|
|
56
|
+
return await execute(envelope.args)
|
|
57
|
+
|
|
58
|
+
async def on_http_request(self, envelope, execute):
|
|
59
|
+
return await execute(envelope.args)
|
|
60
|
+
|
|
61
|
+
hooks = PassThroughHooks()
|
|
62
|
+
integrator = OpenClawRuntimeIntegrator(
|
|
63
|
+
hooks=hooks, # type: ignore[arg-type]
|
|
64
|
+
context_builder=build_context,
|
|
65
|
+
)
|
|
66
|
+
registry = MockRegistry()
|
|
67
|
+
integrator.register(registry)
|
|
68
|
+
|
|
69
|
+
print(await registry.invoke("cmd.run", {"command": "echo hello"}))
|
|
70
|
+
print(await registry.invoke("fs.readFile", {"path": "./README.md"}))
|
|
71
|
+
print(await registry.invoke("http.request", {"url": "https://example.com"}))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
asyncio.run(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "predicate-claw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript OpenClaw security provider with Predicate Authority pre-execution checks.",
|
|
5
|
+
"main": "dist/src/index.js",
|
|
6
|
+
"types": "dist/src/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:demo": "vitest run tests/hack-vs-fix-demo.test.ts",
|
|
12
|
+
"test:ci": "npm run typecheck && npm test"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "(MIT OR Apache-2.0)",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@predicatesystems/authority": "^0.3.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^25.3.0",
|
|
23
|
+
"openclaw": "^2026.2.19-2",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.0.18"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openclaw-predicate-provider"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OpenClaw provider for deterministic Predicate Authority enforcement."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT OR Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Predicate Systems" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"pydantic>=2.7.0",
|
|
21
|
+
"httpx>=0.27.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
openclaw-predicate-provider = "openclaw_predicate_provider.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
include = ["openclaw_predicate_provider*"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
openclaw_predicate_provider = ["py.typed"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
python_files = ["test_*.py"]
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { GuardedProvider } from "./provider.js";
|
|
2
|
+
|
|
3
|
+
export interface ToolRunArgs {
|
|
4
|
+
args: Record<string, unknown>;
|
|
5
|
+
context: Record<string, unknown>;
|
|
6
|
+
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ToolAdapter {
|
|
10
|
+
constructor(private readonly guard: Pick<GuardedProvider, "guardOrThrow">) {}
|
|
11
|
+
|
|
12
|
+
async run(params: ToolRunArgs & { action: string; resource: string }) {
|
|
13
|
+
await this.guard.guardOrThrow({
|
|
14
|
+
action: params.action,
|
|
15
|
+
resource: params.resource,
|
|
16
|
+
args: params.args,
|
|
17
|
+
context: params.context,
|
|
18
|
+
});
|
|
19
|
+
return params.execute(params.args);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async runShell(params: ToolRunArgs) {
|
|
23
|
+
return this.run({
|
|
24
|
+
...params,
|
|
25
|
+
action: "shell.execute",
|
|
26
|
+
resource: String(params.args.command ?? ""),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async readFile(params: ToolRunArgs) {
|
|
31
|
+
return this.run({
|
|
32
|
+
...params,
|
|
33
|
+
action: "fs.read",
|
|
34
|
+
resource: String(params.args.path ?? ""),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async httpRequest(params: ToolRunArgs) {
|
|
39
|
+
return this.run({
|
|
40
|
+
...params,
|
|
41
|
+
action: "net.http",
|
|
42
|
+
resource: String(params.args.url ?? ""),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorityClient,
|
|
3
|
+
type AuthorizationRequest,
|
|
4
|
+
} from "@predicatesystems/authority";
|
|
5
|
+
import type { ProviderConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export interface AuthorityDecision {
|
|
8
|
+
allow: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
mandateId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AuthorityAdapter {
|
|
14
|
+
authorize(request: AuthorizationRequest): Promise<AuthorityDecision>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SdkDecision {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
mandate_id?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SdkLike {
|
|
24
|
+
authorize(request: AuthorizationRequest): Promise<SdkDecision>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createAuthorityAdapter(client: SdkLike): AuthorityAdapter {
|
|
28
|
+
return {
|
|
29
|
+
async authorize(request: AuthorizationRequest): Promise<AuthorityDecision> {
|
|
30
|
+
const decision = await client.authorize(request);
|
|
31
|
+
return {
|
|
32
|
+
allow: decision.allowed,
|
|
33
|
+
reason: decision.reason,
|
|
34
|
+
mandateId: decision.mandate_id ?? undefined,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDefaultAuthorityAdapter(
|
|
41
|
+
config: ProviderConfig,
|
|
42
|
+
): AuthorityAdapter {
|
|
43
|
+
const sdkClient = new AuthorityClient({
|
|
44
|
+
baseUrl: config.baseUrl,
|
|
45
|
+
timeoutMs: config.timeoutMs,
|
|
46
|
+
maxRetries: config.maxRetries,
|
|
47
|
+
backoffInitialMs: config.backoffInitialMs,
|
|
48
|
+
});
|
|
49
|
+
return createAuthorityAdapter(sdkClient);
|
|
50
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker for sidecar outage resilience.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* - CLOSED: Normal operation, requests pass through
|
|
6
|
+
* - OPEN: Too many failures, requests fail fast without calling sidecar
|
|
7
|
+
* - HALF_OPEN: Testing if sidecar has recovered
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type CircuitState = "closed" | "open" | "half_open";
|
|
11
|
+
|
|
12
|
+
export interface CircuitBreakerConfig {
|
|
13
|
+
/** Number of failures before opening the circuit */
|
|
14
|
+
failureThreshold: number;
|
|
15
|
+
/** Time in ms before attempting recovery (half-open state) */
|
|
16
|
+
resetTimeoutMs: number;
|
|
17
|
+
/** Number of successful calls in half-open to close circuit */
|
|
18
|
+
successThreshold: number;
|
|
19
|
+
/** Optional callback when state changes */
|
|
20
|
+
onStateChange?: (from: CircuitState, to: CircuitState) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const defaultCircuitBreakerConfig: CircuitBreakerConfig = {
|
|
24
|
+
failureThreshold: 5,
|
|
25
|
+
resetTimeoutMs: 30_000,
|
|
26
|
+
successThreshold: 2,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface CircuitBreakerMetrics {
|
|
30
|
+
state: CircuitState;
|
|
31
|
+
failureCount: number;
|
|
32
|
+
successCount: number;
|
|
33
|
+
lastFailureTime: number | null;
|
|
34
|
+
totalFailures: number;
|
|
35
|
+
totalSuccesses: number;
|
|
36
|
+
totalRejections: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class CircuitBreaker {
|
|
40
|
+
private state: CircuitState = "closed";
|
|
41
|
+
private failureCount = 0;
|
|
42
|
+
private successCount = 0;
|
|
43
|
+
private lastFailureTime: number | null = null;
|
|
44
|
+
private totalFailures = 0;
|
|
45
|
+
private totalSuccesses = 0;
|
|
46
|
+
private totalRejections = 0;
|
|
47
|
+
|
|
48
|
+
constructor(private readonly config: CircuitBreakerConfig) {}
|
|
49
|
+
|
|
50
|
+
getMetrics(): CircuitBreakerMetrics {
|
|
51
|
+
return {
|
|
52
|
+
state: this.state,
|
|
53
|
+
failureCount: this.failureCount,
|
|
54
|
+
successCount: this.successCount,
|
|
55
|
+
lastFailureTime: this.lastFailureTime,
|
|
56
|
+
totalFailures: this.totalFailures,
|
|
57
|
+
totalSuccesses: this.totalSuccesses,
|
|
58
|
+
totalRejections: this.totalRejections,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getState(): CircuitState {
|
|
63
|
+
return this.state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if request should be allowed through.
|
|
68
|
+
* Returns true if allowed, false if circuit is open.
|
|
69
|
+
*/
|
|
70
|
+
allowRequest(): boolean {
|
|
71
|
+
if (this.state === "closed") {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.state === "open") {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
if (
|
|
78
|
+
this.lastFailureTime !== null &&
|
|
79
|
+
now - this.lastFailureTime >= this.config.resetTimeoutMs
|
|
80
|
+
) {
|
|
81
|
+
this.transitionTo("half_open");
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
this.totalRejections++;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// half_open: allow limited requests to test recovery
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Record a successful call.
|
|
94
|
+
*/
|
|
95
|
+
recordSuccess(): void {
|
|
96
|
+
this.totalSuccesses++;
|
|
97
|
+
|
|
98
|
+
if (this.state === "half_open") {
|
|
99
|
+
this.successCount++;
|
|
100
|
+
if (this.successCount >= this.config.successThreshold) {
|
|
101
|
+
this.transitionTo("closed");
|
|
102
|
+
}
|
|
103
|
+
} else if (this.state === "closed") {
|
|
104
|
+
// Reset failure count on success
|
|
105
|
+
this.failureCount = 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record a failed call.
|
|
111
|
+
*/
|
|
112
|
+
recordFailure(): void {
|
|
113
|
+
this.totalFailures++;
|
|
114
|
+
this.lastFailureTime = Date.now();
|
|
115
|
+
|
|
116
|
+
if (this.state === "half_open") {
|
|
117
|
+
// Any failure in half-open reopens the circuit
|
|
118
|
+
this.transitionTo("open");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (this.state === "closed") {
|
|
123
|
+
this.failureCount++;
|
|
124
|
+
if (this.failureCount >= this.config.failureThreshold) {
|
|
125
|
+
this.transitionTo("open");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Force reset to closed state (e.g., for testing or manual recovery).
|
|
132
|
+
*/
|
|
133
|
+
reset(): void {
|
|
134
|
+
this.transitionTo("closed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private transitionTo(newState: CircuitState): void {
|
|
138
|
+
if (this.state === newState) return;
|
|
139
|
+
|
|
140
|
+
const oldState = this.state;
|
|
141
|
+
this.state = newState;
|
|
142
|
+
|
|
143
|
+
// Reset counters on state change
|
|
144
|
+
if (newState === "closed") {
|
|
145
|
+
this.failureCount = 0;
|
|
146
|
+
this.successCount = 0;
|
|
147
|
+
} else if (newState === "half_open") {
|
|
148
|
+
this.successCount = 0;
|
|
149
|
+
} else if (newState === "open") {
|
|
150
|
+
this.successCount = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.config.onStateChange?.(oldState, newState);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Exponential backoff calculator with jitter.
|
|
159
|
+
*/
|
|
160
|
+
export interface BackoffConfig {
|
|
161
|
+
initialMs: number;
|
|
162
|
+
maxMs: number;
|
|
163
|
+
multiplier: number;
|
|
164
|
+
jitterFactor: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const defaultBackoffConfig: BackoffConfig = {
|
|
168
|
+
initialMs: 100,
|
|
169
|
+
maxMs: 10_000,
|
|
170
|
+
multiplier: 2,
|
|
171
|
+
jitterFactor: 0.1,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export function calculateBackoff(
|
|
175
|
+
attempt: number,
|
|
176
|
+
config: BackoffConfig = defaultBackoffConfig,
|
|
177
|
+
): number {
|
|
178
|
+
const base = Math.min(
|
|
179
|
+
config.initialMs * Math.pow(config.multiplier, attempt),
|
|
180
|
+
config.maxMs,
|
|
181
|
+
);
|
|
182
|
+
const jitter = base * config.jitterFactor * (Math.random() * 2 - 1);
|
|
183
|
+
return Math.max(0, Math.round(base + jitter));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Sleep for a given number of milliseconds.
|
|
188
|
+
*/
|
|
189
|
+
export function sleep(ms: number): Promise<void> {
|
|
190
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Wrap an async function with circuit breaker and retry logic.
|
|
195
|
+
*/
|
|
196
|
+
export async function withCircuitBreaker<T>(
|
|
197
|
+
breaker: CircuitBreaker,
|
|
198
|
+
fn: () => Promise<T>,
|
|
199
|
+
options?: {
|
|
200
|
+
maxRetries?: number;
|
|
201
|
+
backoffConfig?: BackoffConfig;
|
|
202
|
+
isFailure?: (error: unknown) => boolean;
|
|
203
|
+
},
|
|
204
|
+
): Promise<T> {
|
|
205
|
+
const maxRetries = options?.maxRetries ?? 0;
|
|
206
|
+
const backoffConfig = options?.backoffConfig ?? defaultBackoffConfig;
|
|
207
|
+
const isFailure = options?.isFailure ?? (() => true);
|
|
208
|
+
|
|
209
|
+
if (!breaker.allowRequest()) {
|
|
210
|
+
throw new CircuitOpenError("Circuit breaker is open");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let lastError: unknown;
|
|
214
|
+
|
|
215
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
216
|
+
try {
|
|
217
|
+
const result = await fn();
|
|
218
|
+
breaker.recordSuccess();
|
|
219
|
+
return result;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
lastError = error;
|
|
222
|
+
|
|
223
|
+
if (!isFailure(error)) {
|
|
224
|
+
// Not a circuit-breaker-relevant failure (e.g., business logic error)
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
breaker.recordFailure();
|
|
229
|
+
|
|
230
|
+
if (attempt < maxRetries && breaker.allowRequest()) {
|
|
231
|
+
const delay = calculateBackoff(attempt, backoffConfig);
|
|
232
|
+
await sleep(delay);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw lastError;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export class CircuitOpenError extends Error {
|
|
241
|
+
constructor(message: string) {
|
|
242
|
+
super(message);
|
|
243
|
+
this.name = "CircuitOpenError";
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ProviderConfig {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
timeoutMs: number;
|
|
4
|
+
maxRetries: number;
|
|
5
|
+
backoffInitialMs: number;
|
|
6
|
+
failClosed: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const defaultProviderConfig: ProviderConfig = {
|
|
10
|
+
baseUrl: "http://127.0.0.1:8787",
|
|
11
|
+
timeoutMs: 300,
|
|
12
|
+
maxRetries: 0,
|
|
13
|
+
backoffInitialMs: 100,
|
|
14
|
+
failClosed: true,
|
|
15
|
+
};
|