stream-error-envelope-audit 0.0.0-stage → 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-10-27
4
+
5
+ - Add bounded normalization for direct, `error`, and `data.error` envelopes.
6
+ - Add conservative retry evidence classification and unsafe-replay diagnostics.
7
+ - Ship ESM, CommonJS, TypeScript declarations, tests, and a runnable example.
@@ -0,0 +1,3 @@
1
+ # Code of Conduct
2
+
3
+ Be respectful, constructive, and specific. Harassment, discrimination, threats, doxxing, and abusive conduct are not accepted. Maintainers may remove harmful content or participation. Report sensitive conduct concerns privately.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Huzaifa Asif
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,5 @@
1
+ # Notice
2
+
3
+ Copyright (c) 2026 Huzaifa Asif.
4
+
5
+ This project is original MIT-licensed code. It references public issue reports and package names for interoperability context; no third-party source code is included.
package/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # stream-error-envelope-audit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Normalize and audit decoded provider or gateway error envelopes without losing the evidence needed for a safe retry decision. The package is deterministic, bounded, zero-dependency, and ships ESM, CommonJS, and TypeScript entry points.
4
+
5
+ It **does not retry requests**, parse SSE bytes, contact a provider, or promise that a retry is safe.
6
+
7
+ ## Why
8
+
9
+ Streaming systems frequently deliver errors through several shapes: a direct error object, an `error` event, or a gateway's `data.error` wrapper. A valid provider code may be null, while status, retry delay, and partial-output state still matter. Rejecting such an envelope as malformed can hide the original failure; blindly retrying can duplicate output or side effects.
10
+
11
+ The package was motivated by current public integration reports:
12
+
13
+ - [Vercel AI #21360](https://github.com/vercel/ai/issues/21360) (2026-09-22): a valid Responses error with `code: null` was rejected by a schema and obscured an upstream retryable 500.
14
+ - [Vercel AI #18303](https://github.com/vercel/ai/issues/18303) (2026-07-31): gateway streaming surfaced a validation error instead of preserving the upstream provider error context.
15
+ - [Vercel AI #22671](https://github.com/vercel/ai/issues/22671) (2026-10-10): callers need failure-aware control over retries, including privacy-sensitive metadata decisions.
16
+ - [Vercel AI #23762](https://github.com/vercel/ai/issues/23762) (2026-10-21): callback timing could skip an expected stream retry, demonstrating that classification and retry execution should remain observable and separate.
17
+
18
+ This is an independent package and is not affiliated with Vercel or any model provider.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm install stream-error-envelope-audit
24
+ ```
25
+
26
+ Node.js 20 or newer is supported.
27
+
28
+ ## Quick start
29
+
30
+ ```js
31
+ import { createErrorEnvelopeAudit } from 'stream-error-envelope-audit';
32
+
33
+ const audit = createErrorEnvelopeAudit({ maxRetryAfterMs: 30_000 });
34
+ const result = audit.observe(
35
+ { type: 'error', error: { code: null, message: 'provider overloaded' } },
36
+ { status: 503, retryAfterMs: 1_000, partialOutput: false },
37
+ );
38
+
39
+ if (result.normalized.retryClass === 'retryable' &&
40
+ !result.issues.some(({ severity }) => severity === 'error') &&
41
+ applicationPolicyAllowsRetry(result.normalized)) {
42
+ scheduleRetry(result.normalized.retryAfterMs);
43
+ }
44
+ ```
45
+
46
+ CommonJS:
47
+
48
+ ```js
49
+ const { createErrorEnvelopeAudit } = require('stream-error-envelope-audit');
50
+ ```
51
+
52
+ ## API
53
+
54
+ ### `createErrorEnvelopeAudit(options?)`
55
+
56
+ - `maxObservations` (default `10_000`) bounds processed envelopes.
57
+ - `maxIssues` (default `1_000`) bounds retained diagnostics.
58
+ - `maxTextLength` (default `512`) truncates copied code, type, and message strings.
59
+ - `maxRetryAfterMs` (default `120_000`) marks delays beyond an application budget.
60
+
61
+ ### `audit.observe(raw, context?)`
62
+
63
+ Recognizes a direct object, `raw.error`, or `raw.data.error`. Context may contain:
64
+
65
+ - `status`: HTTP-like numeric status.
66
+ - `retryable`: explicit caller or adapter hint.
67
+ - `retryAfterMs`: already-parsed delay in milliseconds.
68
+ - `partialOutput`: output was exposed before the failure.
69
+ - `sideEffectStarted`: a tool or other side effect may have begun.
70
+
71
+ The immutable normalized result includes bounded `code`, `type`, and `message` strings plus `retryClass`: `retryable`, `non_retryable`, or `unknown`. The conservative built-in retryable status set is 408, 425, 429, 502, 503, and 504; an explicit boolean hint can override it. Other 5xx statuses remain unknown. Partial output or a possible side effect always reduces the class to `unknown`; the package never asserts idempotency.
72
+
73
+ ### `audit.normalize(raw, context?)`
74
+
75
+ Normalizes without incrementing counts or retaining diagnostics. It returns `null` for a non-object envelope.
76
+
77
+ ### `audit.snapshot()` and `audit.finalize()`
78
+
79
+ Snapshots report bounded counters only. `finalize()` seals the audit and returns retained issues. `valid` means no error-severity diagnostic occurred; warning-only metadata gaps such as a nullable code remain valid. Source payloads, headers, stack traces, and arbitrary fields are never retained.
80
+
81
+ ## Diagnostics
82
+
83
+ - warning: `missing-code` and `missing-message`
84
+ - error: `invalid-status`
85
+ - `retry-after-exceeds-budget`
86
+ - `retry-contradiction`
87
+ - `partial-output-retry-unsafe`
88
+ - `side-effect-retry-unsafe`
89
+ - `invalid-envelope`, `invalid-context`, and `observation-limit`
90
+
91
+ ## Limitations
92
+
93
+ - Classification uses status and explicit hints; it cannot know a provider's complete retry contract.
94
+ - It does not parse `Retry-After` header strings. Parse and validate them at your HTTP boundary.
95
+ - It does not retain response bodies or headers, deduplicate retries, calculate backoff, or execute a retry.
96
+ - `retryable` means the envelope contains retry evidence, not that replay is safe. Idempotency, billing, privacy, partial output, and side effects remain application decisions.
97
+ - Hostile getters are treated as missing fields; the package cannot make arbitrary proxy objects safe.
98
+
99
+ ## Alternatives
100
+
101
+ - Provider SDK retry logic is preferable when its semantics and telemetry meet your needs.
102
+ - [`p-retry`](https://www.npmjs.com/package/p-retry) and [`async-retry`](https://www.npmjs.com/package/async-retry) execute retry policies; this package only audits decoded evidence.
103
+ - [`ai-stream-auditor`](https://www.npmjs.com/package/ai-stream-auditor) validates OpenAI/Anthropic event lifecycles and terminal outcomes; it does not normalize arbitrary gateway error envelopes.
104
+ - `stream-part-lifecycle`, `stream-sequence-fence`, and `abort-settlement-audit` cover keyed parts, ordering/replay, and cancellation settlement respectively—not error-shape preservation.
105
+
106
+ ## Troubleshooting
107
+
108
+ - `missing-code`: do not reject the event solely because code is null; use other evidence.
109
+ - `retry-contradiction`: stop automatic retry and inspect the adapter/provider contract.
110
+ - `partial-output-retry-unsafe`: reconcile displayed or persisted output before any replay.
111
+ - `side-effect-retry-unsafe`: require an idempotency key or manual decision.
112
+ - `retry-after-exceeds-budget`: fail or defer according to your latency budget.
113
+
114
+ ## Security and privacy
115
+
116
+ Pass only already-decoded error metadata. Do not include secrets in messages or codes. See [SECURITY.md](SECURITY.md).
117
+
118
+ ## License
119
+
120
+ MIT. See [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md).
package/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security policy
2
+
3
+ ## Supported version
4
+
5
+ The latest released major version receives security fixes.
6
+
7
+ ## Reporting
8
+
9
+ Report vulnerabilities privately through GitHub Security Advisories for `Huzaifa-Asif/stream-error-envelope-audit`. Do not open a public issue containing credentials, sensitive error bodies, or exploit details.
10
+
11
+ The package does not make untrusted values safe for logging. It copies only bounded code/type/message fields, but callers should redact secrets before observation.
@@ -0,0 +1,8 @@
1
+ import { createErrorEnvelopeAudit } from '../src/index.mjs';
2
+
3
+ const audit = createErrorEnvelopeAudit({ maxRetryAfterMs: 30_000 });
4
+ const result = audit.observe(
5
+ { type: 'error', error: { code: null, message: 'provider overloaded', retryable: true } },
6
+ { status: 503, retryAfterMs: 1_000, partialOutput: false },
7
+ );
8
+ console.log(JSON.stringify({ retryClass: result.normalized.retryClass, issues: result.issues.map(({ code }) => code) }));
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "stream-error-envelope-audit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Normalize and audit provider error envelopes without losing retry evidence.",
5
+ "type": "module",
6
+ "main": "./src/index.cjs",
7
+ "module": "./src/index.mjs",
8
+ "types": "./src/index.d.ts",
9
+ "exports": { ".": { "types": "./src/index.d.ts", "import": "./src/index.mjs", "require": "./src/index.cjs" } },
10
+ "files": ["src/", "examples/", "README.md", "LICENSE", "NOTICE.md", "CHANGELOG.md", "SECURITY.md", "CODE_OF_CONDUCT.md"],
11
+ "scripts": {
12
+ "build": "node scripts/verify-package.mjs",
13
+ "lint": "node scripts/lint.mjs",
14
+ "test": "node --test --experimental-test-coverage",
15
+ "example": "node examples/audit-errors.mjs",
16
+ "check": "npm run lint && npm run build && npm test && npm run example && npm pack --dry-run --json --cache ./.npm-cache"
17
+ },
18
+ "engines": { "node": ">=20" },
19
+ "keywords": ["ai", "stream", "error", "retry", "audit", "diagnostics"],
20
+ "license": "MIT",
21
+ "author": "Huzaifa Asif",
22
+ "repository": { "type": "git", "url": "git+https://github.com/Huzaifa-Asif/stream-error-envelope-audit.git" },
23
+ "bugs": { "url": "https://github.com/Huzaifa-Asif/stream-error-envelope-audit/issues" },
24
+ "homepage": "https://github.com/Huzaifa-Asif/stream-error-envelope-audit#readme",
25
+ "publishConfig": { "access": "public" }
26
+ }
package/src/index.cjs ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ function positiveInteger(value, name, fallback) {
4
+ if (value === undefined) return fallback;
5
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${name} must be a positive safe integer`);
6
+ return value;
7
+ }
8
+
9
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
10
+ function own(value, key) { return object(value) && Object.prototype.hasOwnProperty.call(value, key); }
11
+ function safeRead(value, key) { try { return own(value, key) ? value[key] : undefined; } catch { return undefined; } }
12
+ function text(value, limit) { return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : null; }
13
+ function number(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; }
14
+ function httpStatus(value) { return Number.isSafeInteger(value) && value >= 100 && value <= 599 ? value : null; }
15
+
16
+ function createErrorEnvelopeAudit(options = {}) {
17
+ if (!object(options)) throw new TypeError('options must be an object');
18
+ const maxObservations = positiveInteger(options.maxObservations, 'maxObservations', 10_000);
19
+ const maxIssues = positiveInteger(options.maxIssues, 'maxIssues', 1_000);
20
+ const maxTextLength = positiveInteger(options.maxTextLength, 'maxTextLength', 512);
21
+ const maxRetryAfterMs = positiveInteger(options.maxRetryAfterMs, 'maxRetryAfterMs', 120_000);
22
+ const issues = [];
23
+ const counts = { retryable: 0, non_retryable: 0, unknown: 0 };
24
+ let observations = 0;
25
+ let totalIssues = 0;
26
+ let errorIssues = 0;
27
+ let warningIssues = 0;
28
+ let droppedIssues = 0;
29
+ let finalized = false;
30
+
31
+ function retain(code, message, detail = {}, severity = 'error') {
32
+ totalIssues += 1;
33
+ if (severity === 'warning') warningIssues += 1; else errorIssues += 1;
34
+ const issue = Object.freeze({ code, severity, observation: observations, message, ...detail });
35
+ if (issues.length < maxIssues) issues.push(issue); else droppedIssues += 1;
36
+ return issue;
37
+ }
38
+
39
+ function findEnvelope(raw) {
40
+ if (!object(raw)) return null;
41
+ const direct = safeRead(raw, 'error');
42
+ if (object(direct)) return direct;
43
+ const data = safeRead(raw, 'data');
44
+ const nested = safeRead(data, 'error');
45
+ return object(nested) ? nested : raw;
46
+ }
47
+
48
+ function normalize(raw, context = {}) {
49
+ if (!object(context)) throw new TypeError('context must be an object');
50
+ const envelope = findEnvelope(raw);
51
+ if (!envelope) return null;
52
+ const contextStatus = safeRead(context, 'status');
53
+ const rawStatus = safeRead(raw, 'status');
54
+ const envelopeStatus = envelope === raw ? rawStatus : safeRead(envelope, 'status');
55
+ const suppliedStatus = contextStatus ?? rawStatus ?? envelopeStatus;
56
+ const status = httpStatus(suppliedStatus);
57
+ const contextRetryable = safeRead(context, 'retryable');
58
+ const envelopeRetryable = safeRead(envelope, 'retryable');
59
+ const explicitRetryable = typeof contextRetryable === 'boolean'
60
+ ? contextRetryable
61
+ : (typeof envelopeRetryable === 'boolean' ? envelopeRetryable : null);
62
+ const contextRetryAfter = safeRead(context, 'retryAfterMs');
63
+ const envelopeRetryAfter = safeRead(envelope, 'retryAfterMs');
64
+ const rawRetryAfter = number(contextRetryAfter) ?? number(envelopeRetryAfter);
65
+ const retryAfterMs = rawRetryAfter !== null && rawRetryAfter >= 0 ? rawRetryAfter : null;
66
+ const codeValue = safeRead(envelope, 'code');
67
+ const code = typeof codeValue === 'string' || typeof codeValue === 'number' ? String(codeValue).slice(0, maxTextLength) : null;
68
+ const type = text(safeRead(envelope, 'type'), maxTextLength);
69
+ const envelopeMessage = safeRead(envelope, 'message');
70
+ const rawMessage = envelope === raw ? envelopeMessage : safeRead(raw, 'message');
71
+ const message = text(envelopeMessage, maxTextLength) ?? text(rawMessage, maxTextLength);
72
+ const partialOutput = safeRead(context, 'partialOutput') === true;
73
+ const sideEffectStarted = safeRead(context, 'sideEffectStarted') === true;
74
+ let retryClass = 'unknown';
75
+ if (explicitRetryable === true || [408, 425, 429, 502, 503, 504].includes(status)) retryClass = 'retryable';
76
+ if (explicitRetryable === false || (status !== null && status >= 400 && status <= 499 && ![408, 425, 429].includes(status))) retryClass = 'non_retryable';
77
+ if (partialOutput || sideEffectStarted) retryClass = 'unknown';
78
+ return Object.freeze({ code, type, message, status, retryAfterMs, explicitRetryable, partialOutput, sideEffectStarted, retryClass, statusSupplied: suppliedStatus !== undefined && suppliedStatus !== null });
79
+ }
80
+
81
+ function snapshot() {
82
+ return Object.freeze({ observations, counts: Object.freeze({ ...counts }), issueCount: totalIssues, errorIssueCount: errorIssues, warningIssueCount: warningIssues, retainedIssueCount: issues.length, droppedIssues, finalized });
83
+ }
84
+
85
+ function observe(raw, context = {}) {
86
+ if (finalized) throw new Error('audit is finalized');
87
+ if (observations >= maxObservations) return Object.freeze({ status: 'rejected', normalized: null, issues: Object.freeze([retain('observation-limit', 'observation would exceed maxObservations', { limit: maxObservations })]), snapshot: snapshot() });
88
+ observations += 1;
89
+ let normalized;
90
+ try { normalized = normalize(raw, context); }
91
+ catch (error) { return Object.freeze({ status: 'rejected', normalized: null, issues: Object.freeze([retain('invalid-context', error.message)]), snapshot: snapshot() }); }
92
+ if (!normalized) return Object.freeze({ status: 'rejected', normalized: null, issues: Object.freeze([retain('invalid-envelope', 'error envelope must be a non-array object')]), snapshot: snapshot() });
93
+ counts[normalized.retryClass] += 1;
94
+ const found = [];
95
+ if (normalized.statusSupplied && normalized.status === null) found.push(retain('invalid-status', 'status must be an integer from 100 through 599'));
96
+ if (normalized.code === null) found.push(retain('missing-code', 'error code is absent or null; classification remains evidence-based', {}, 'warning'));
97
+ if (normalized.message === null) found.push(retain('missing-message', 'error message is absent', {}, 'warning'));
98
+ if (normalized.retryAfterMs !== null && normalized.retryAfterMs > maxRetryAfterMs) found.push(retain('retry-after-exceeds-budget', 'retry delay exceeds configured budget', { retryAfterMs: normalized.retryAfterMs, limit: maxRetryAfterMs }));
99
+ if (normalized.explicitRetryable === true && normalized.status !== null && normalized.status >= 400 && normalized.status < 500 && ![408, 425, 429].includes(normalized.status)) found.push(retain('retry-contradiction', 'explicit retryable flag conflicts with a normally terminal client status', { status: normalized.status }));
100
+ if (normalized.partialOutput) found.push(retain('partial-output-retry-unsafe', 'automatic retry is unsafe after partial output without application reconciliation'));
101
+ if (normalized.sideEffectStarted) found.push(retain('side-effect-retry-unsafe', 'automatic retry is unsafe after a side effect may have started'));
102
+ return Object.freeze({ status: 'accepted', normalized, issues: Object.freeze(found), snapshot: snapshot() });
103
+ }
104
+
105
+ function finalize() {
106
+ finalized = true;
107
+ return Object.freeze({ valid: errorIssues === 0, issues: Object.freeze([...issues]), snapshot: snapshot() });
108
+ }
109
+
110
+ return Object.freeze({ observe, normalize, snapshot, finalize });
111
+ }
112
+
113
+ module.exports = { createErrorEnvelopeAudit };
package/src/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type RetryClass = 'retryable' | 'non_retryable' | 'unknown';
2
+ export interface ErrorContext { readonly status?: number; readonly retryable?: boolean; readonly retryAfterMs?: number; readonly partialOutput?: boolean; readonly sideEffectStarted?: boolean; }
3
+ export interface NormalizedError { readonly code: string | null; readonly type: string | null; readonly message: string | null; readonly status: number | null; readonly retryAfterMs: number | null; readonly explicitRetryable: boolean | null; readonly partialOutput: boolean; readonly sideEffectStarted: boolean; readonly retryClass: RetryClass; readonly statusSupplied: boolean; }
4
+ export interface AuditIssue { readonly code: string; readonly severity: 'warning' | 'error'; readonly observation: number; readonly message: string; readonly [key: string]: unknown; }
5
+ export interface AuditSnapshot { readonly observations: number; readonly counts: Readonly<Record<RetryClass, number>>; readonly issueCount: number; readonly errorIssueCount: number; readonly warningIssueCount: number; readonly retainedIssueCount: number; readonly droppedIssues: number; readonly finalized: boolean; }
6
+ export interface ObserveResult { readonly status: 'accepted' | 'rejected'; readonly normalized: NormalizedError | null; readonly issues: readonly AuditIssue[]; readonly snapshot: AuditSnapshot; }
7
+ export interface ErrorEnvelopeAudit { observe(raw: unknown, context?: ErrorContext): ObserveResult; normalize(raw: unknown, context?: ErrorContext): NormalizedError | null; snapshot(): AuditSnapshot; finalize(): { readonly valid: boolean; readonly issues: readonly AuditIssue[]; readonly snapshot: AuditSnapshot }; }
8
+ export interface ErrorEnvelopeAuditOptions { readonly maxObservations?: number; readonly maxIssues?: number; readonly maxTextLength?: number; readonly maxRetryAfterMs?: number; }
9
+ export declare function createErrorEnvelopeAudit(options?: ErrorEnvelopeAuditOptions): ErrorEnvelopeAudit;
10
+ declare const api: { createErrorEnvelopeAudit: typeof createErrorEnvelopeAudit };
11
+ export default api;
package/src/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import api from './index.cjs';
2
+ export const { createErrorEnvelopeAudit } = api;
3
+ export default api;