@driftgard/node 1.5.0 → 1.5.1

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/README.md CHANGED
@@ -51,7 +51,6 @@ const result = await dg.evaluate({
51
51
  ```
52
52
 
53
53
  This enables chain depth protection (prevents infinite agent loops) and lets you trace evaluation lineage in the dashboard.
54
- ```
55
54
 
56
55
  ## A/B experiments
57
56
 
@@ -89,11 +88,13 @@ const result = await dg.evaluate({
89
88
  ```
90
89
 
91
90
  All fields in `usage` are optional. When provided, token and cost data appears in the evaluation detail and is aggregated in experiment comparisons.
92
- ```
93
91
 
94
92
  ## Features
95
93
 
96
94
  - Single `evaluate()` method — send prompt/response, get verdict
95
+ - Failure mode: `fail-open` or `fail-closed` when API is unreachable
96
+ - Circuit breaker: skips API after consecutive failures, auto-recovers
97
+ - Idempotency: deduplicates retried requests via `x-idempotency-key`
97
98
  - Auto-retry with exponential backoff on 5xx and network errors
98
99
  - Typed errors: `AuthError`, `RateLimitError`, `FeatureNotAvailableError`, `ChainDepthExceededError`
99
100
  - Full TypeScript types for requests and responses
@@ -107,9 +108,35 @@ const dg = new Driftgard({
107
108
  baseUrl: "https://api.driftgard.com", // optional
108
109
  timeout: 30000, // optional, ms (default 30s)
109
110
  maxRetries: 2, // optional (default 2)
111
+ failureMode: "open", // "open" = allow if API down, "closed" = block (default "open")
112
+ circuitBreaker: {
113
+ threshold: 5, // open circuit after 5 consecutive failures (default 5)
114
+ resetTimeoutMs: 30000, // try again after 30s (default 30000)
115
+ },
110
116
  });
111
117
  ```
112
118
 
119
+ ## Failure mode & circuit breaker
120
+
121
+ The SDK never throws on network/server errors during `evaluate()`. Instead, it returns a synthetic response:
122
+
123
+ ```typescript
124
+ const result = await dg.evaluate({ ... });
125
+
126
+ // Check where the decision came from
127
+ console.log(result.decision_source);
128
+ // "policy" — normal API evaluation
129
+ // "failure_mode" — API unreachable, failureMode applied
130
+ // "circuit_open" — circuit breaker open, failureMode applied
131
+ // "idempotency_cache" — duplicate request, cached result returned
132
+
133
+ // Monitor circuit breaker state
134
+ console.log(dg.circuitBreakerState);
135
+ // { state: "closed", failures: 0, openedAt: 0 }
136
+ ```
137
+
138
+ With `failureMode: "open"` (default), the SDK allows requests through when Driftgard is unavailable. With `failureMode: "closed"`, it blocks them with a fallback message.
139
+
113
140
  ## Error handling
114
141
 
115
142
  ```typescript
package/dist/index.d.ts CHANGED
@@ -1,17 +1,34 @@
1
1
  import { DriftgardConfig, EvaluateRequest, EvaluateResponse } from "./types";
2
- export { DriftgardConfig, EvaluateRequest, EvaluateResponse } from "./types";
2
+ export { DriftgardConfig, EvaluateRequest, EvaluateResponse, CircuitBreakerConfig } from "./types";
3
3
  export { Violation, EvaluationResult, FallbackResponse, HitlInfo } from "./types";
4
4
  export { DriftgardError, AuthError, RateLimitError, FeatureNotAvailableError, ChainDepthExceededError } from "./errors";
5
+ type CircuitState = "closed" | "open" | "half-open";
5
6
  export declare class Driftgard {
6
7
  private apiKey;
7
8
  private baseUrl;
8
9
  private timeout;
9
10
  private maxRetries;
11
+ private failureMode;
12
+ private cbThreshold;
13
+ private cbResetMs;
14
+ private cbState;
15
+ private cbFailures;
16
+ private cbOpenedAt;
10
17
  constructor(config: DriftgardConfig);
11
18
  /**
12
19
  * Evaluate a prompt/response pair against your active control pack.
20
+ * Handles circuit breaker and failure mode automatically.
13
21
  */
14
22
  evaluate(req: EvaluateRequest): Promise<EvaluateResponse>;
23
+ /** Generate a synthetic response based on failure mode. */
24
+ private syntheticResponse;
25
+ /** Get current circuit breaker state (for observability). */
26
+ get circuitBreakerState(): {
27
+ state: CircuitState;
28
+ failures: number;
29
+ openedAt: number;
30
+ };
31
+ private generateIdempotencyKey;
15
32
  private post;
16
33
  private delay;
17
34
  }
package/dist/index.js CHANGED
@@ -12,35 +12,119 @@ const DEFAULT_BASE_URL = "https://api.driftgard.com";
12
12
  const DEFAULT_TIMEOUT = 30000;
13
13
  const DEFAULT_MAX_RETRIES = 2;
14
14
  const RETRY_DELAY_MS = 500;
15
+ const DEFAULT_CB_THRESHOLD = 5;
16
+ const DEFAULT_CB_RESET_MS = 30000;
15
17
  class Driftgard {
16
18
  constructor(config) {
19
+ this.cbState = "closed";
20
+ this.cbFailures = 0;
21
+ this.cbOpenedAt = 0;
17
22
  if (!config.apiKey)
18
23
  throw new Error("apiKey is required");
19
24
  this.apiKey = config.apiKey;
20
25
  this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
21
26
  this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
22
27
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
28
+ this.failureMode = config.failureMode ?? "open";
29
+ this.cbThreshold = config.circuitBreaker?.threshold ?? DEFAULT_CB_THRESHOLD;
30
+ this.cbResetMs = config.circuitBreaker?.resetTimeoutMs ?? DEFAULT_CB_RESET_MS;
23
31
  }
24
32
  /**
25
33
  * Evaluate a prompt/response pair against your active control pack.
34
+ * Handles circuit breaker and failure mode automatically.
26
35
  */
27
36
  async evaluate(req) {
28
- return this.post("/audit/evaluate", {
37
+ const idempotencyKey = req.idempotency_key || this.generateIdempotencyKey();
38
+ // Circuit breaker: if open, check if reset timeout has passed
39
+ if (this.cbState === "open") {
40
+ if (Date.now() - this.cbOpenedAt >= this.cbResetMs) {
41
+ this.cbState = "half-open";
42
+ }
43
+ else {
44
+ // Circuit is open — apply failure mode without calling API
45
+ return this.syntheticResponse(req, "circuit_open");
46
+ }
47
+ }
48
+ try {
49
+ const result = await this.post("/audit/evaluate", {
50
+ project_id: req.project_id,
51
+ prompt: req.prompt,
52
+ response: req.response,
53
+ model_id: req.model_id,
54
+ timestamp: req.timestamp || new Date().toISOString(),
55
+ ...(req.experiment_id ? { experiment_id: req.experiment_id } : {}),
56
+ ...(req.session_id ? { session_id: req.session_id } : {}),
57
+ ...(req.parent_evaluation_id ? { parent_evaluation_id: req.parent_evaluation_id } : {}),
58
+ ...(req.control_pack_id ? { control_pack_id: req.control_pack_id } : {}),
59
+ ...(req.control_pack_version ? { control_pack_version: req.control_pack_version } : {}),
60
+ ...(req.dry_run ? { dry_run: req.dry_run } : {}),
61
+ ...(req.usage ? { usage: req.usage } : {}),
62
+ }, idempotencyKey);
63
+ // Success — reset circuit breaker
64
+ this.cbFailures = 0;
65
+ this.cbState = "closed";
66
+ return result;
67
+ }
68
+ catch (e) {
69
+ // Auth, rate limit, chain depth, feature errors are real errors — don't trigger circuit breaker
70
+ if (e instanceof errors_1.AuthError || e instanceof errors_1.RateLimitError ||
71
+ e instanceof errors_1.ChainDepthExceededError || e instanceof errors_1.FeatureNotAvailableError) {
72
+ throw e;
73
+ }
74
+ // Network/server error — increment circuit breaker
75
+ this.cbFailures++;
76
+ if (this.cbFailures >= this.cbThreshold) {
77
+ this.cbState = "open";
78
+ this.cbOpenedAt = Date.now();
79
+ }
80
+ // Apply failure mode
81
+ return this.syntheticResponse(req, "failure_mode");
82
+ }
83
+ }
84
+ /** Generate a synthetic response based on failure mode. */
85
+ syntheticResponse(req, source) {
86
+ const allowed = this.failureMode === "open";
87
+ return {
88
+ ok: true,
29
89
  project_id: req.project_id,
30
- prompt: req.prompt,
31
- response: req.response,
32
- model_id: req.model_id,
33
- timestamp: req.timestamp || new Date().toISOString(),
34
- ...(req.experiment_id ? { experiment_id: req.experiment_id } : {}),
35
- ...(req.session_id ? { session_id: req.session_id } : {}),
36
- ...(req.parent_evaluation_id ? { parent_evaluation_id: req.parent_evaluation_id } : {}),
37
- ...(req.control_pack_id ? { control_pack_id: req.control_pack_id } : {}),
38
- ...(req.control_pack_version ? { control_pack_version: req.control_pack_version } : {}),
39
- ...(req.dry_run ? { dry_run: req.dry_run } : {}),
40
- ...(req.usage ? { usage: req.usage } : {}),
41
- });
90
+ evaluation_id: `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
91
+ active_control_pack_id: "",
92
+ active_control_pack_version: 0,
93
+ data_mode: "unknown",
94
+ telemetry_mode: "unknown",
95
+ execution_mode: "sync",
96
+ decision_action: allowed ? "log_only" : "enforce",
97
+ decision_source: source,
98
+ evaluation: {
99
+ allowed,
100
+ risk_score: allowed ? 0 : 10,
101
+ violations: allowed ? [] : [{
102
+ clause_id: "DRIFTGARD_UNAVAILABLE",
103
+ severity: "critical",
104
+ category: "system",
105
+ reason: `Driftgard API unavailable — fail-closed policy applied (${source})`,
106
+ }],
107
+ },
108
+ ...(allowed ? {} : {
109
+ fallback: {
110
+ message: "Service temporarily unavailable. Request blocked by fail-closed policy.",
111
+ code: "DRIFTGARD_UNAVAILABLE",
112
+ action: "show_message",
113
+ source,
114
+ escalated: false,
115
+ retry_after_ms: this.cbState === "open" ? this.cbResetMs : null,
116
+ },
117
+ }),
118
+ };
119
+ }
120
+ /** Get current circuit breaker state (for observability). */
121
+ get circuitBreakerState() {
122
+ return { state: this.cbState, failures: this.cbFailures, openedAt: this.cbOpenedAt };
123
+ }
124
+ generateIdempotencyKey() {
125
+ return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
42
126
  }
43
- async post(path, body, attempt = 0) {
127
+ async post(path, body, idempotencyKey, attempt = 0) {
44
128
  const controller = new AbortController();
45
129
  const timer = setTimeout(() => controller.abort(), this.timeout);
46
130
  try {
@@ -49,6 +133,7 @@ class Driftgard {
49
133
  headers: {
50
134
  "Content-Type": "application/json",
51
135
  "x-api-key": this.apiKey,
136
+ ...(idempotencyKey ? { "x-idempotency-key": idempotencyKey } : {}),
52
137
  },
53
138
  body: JSON.stringify(body),
54
139
  signal: controller.signal,
@@ -72,7 +157,7 @@ class Driftgard {
72
157
  // Retry on 5xx
73
158
  if (res.status >= 500 && attempt < this.maxRetries) {
74
159
  await this.delay(RETRY_DELAY_MS * Math.pow(2, attempt));
75
- return this.post(path, body, attempt + 1);
160
+ return this.post(path, body, idempotencyKey, attempt + 1);
76
161
  }
77
162
  const payload = await res.json();
78
163
  if (!res.ok) {
@@ -86,7 +171,7 @@ class Driftgard {
86
171
  // Network error — retry
87
172
  if (attempt < this.maxRetries) {
88
173
  await this.delay(RETRY_DELAY_MS * Math.pow(2, attempt));
89
- return this.post(path, body, attempt + 1);
174
+ return this.post(path, body, idempotencyKey, attempt + 1);
90
175
  }
91
176
  const msg = e instanceof Error ? e.message : String(e);
92
177
  throw new errors_1.DriftgardError(msg, 0, "network_error");
package/dist/types.d.ts CHANGED
@@ -1,8 +1,18 @@
1
+ export interface CircuitBreakerConfig {
2
+ /** Number of consecutive failures before opening the circuit. Default 5. */
3
+ threshold?: number;
4
+ /** Milliseconds to wait before trying again after circuit opens. Default 30000. */
5
+ resetTimeoutMs?: number;
6
+ }
1
7
  export interface DriftgardConfig {
2
8
  apiKey: string;
3
9
  baseUrl?: string;
4
10
  timeout?: number;
5
11
  maxRetries?: number;
12
+ /** What to do when Driftgard API is unreachable. "open" = allow, "closed" = block. Default "open". */
13
+ failureMode?: "open" | "closed";
14
+ /** Circuit breaker config. Skips API calls after consecutive failures. */
15
+ circuitBreaker?: CircuitBreakerConfig;
6
16
  }
7
17
  export interface EvaluateRequest {
8
18
  project_id: string;
@@ -16,6 +26,8 @@ export interface EvaluateRequest {
16
26
  control_pack_id?: string;
17
27
  control_pack_version?: number;
18
28
  dry_run?: boolean;
29
+ /** Caller-provided idempotency key. If omitted, the SDK generates one. */
30
+ idempotency_key?: string;
19
31
  usage?: {
20
32
  prompt_tokens?: number;
21
33
  completion_tokens?: number;
@@ -34,6 +46,8 @@ export interface EvaluationResult {
34
46
  allowed: boolean;
35
47
  risk_score: number;
36
48
  violations: Violation[];
49
+ /** Original policy decision before execution_mode override (only present when overridden). */
50
+ policy_allowed?: boolean;
37
51
  flags?: {
38
52
  pii_detected?: boolean;
39
53
  pii_in_prompt?: boolean;
@@ -70,6 +84,12 @@ export interface EvaluateResponse {
70
84
  active_control_pack_version: number;
71
85
  data_mode: string;
72
86
  telemetry_mode: string;
87
+ /** Server-side execution mode: sync, async, or hybrid. */
88
+ execution_mode?: string;
89
+ /** How the decision was applied: "enforce" or "log_only". */
90
+ decision_action?: string;
91
+ /** Where the decision came from: "policy", "failure_mode", "circuit_open". */
92
+ decision_source?: string;
73
93
  dry_run?: boolean;
74
94
  experiment_id?: string;
75
95
  session_id?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftgard/node",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Official Driftgard Node.js SDK — evaluate LLM interactions against your compliance policy",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",