Skip to content

Commit fabef73

Browse files
heskewclaude
andcommitted
fix(models): apply multipass review findings
Adjudicated 13 findings from a 6-agent deep-review of #638 (api×2 + data-integrity×2 + concurrency×2). 9 REAL + 1 PARTIAL + 3 NOISE. This commit applies the 9 REAL fixes; PARTIAL (buffer backpressure hardening) and NOISE (matches-existing-pattern) are deferred. **F1 (Blocker) — Replication exclusion.** Added 'hdb_model_calls' to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The new table mimicked `getRawAnalyticsTable()`'s `audit: true` declaration but missed that `hdb_raw_analytics` is in the exclusion list. Without this, per-call rows would replicate to every cluster node — high-cost audit-shape replication (the 150MB ProxiedRequestLog incident pattern) and concurrent writes from two nodes could produce identical process-local `getNextMonotonicTime()` IDs that LWW would silently drop. One-line fix; matches the established convention. **F2 (Important) — Pre-call errors record.** `resolveEmbedding` / `resolveGenerative` / `requireCapability` were called BEFORE the try/catch in `embed`/`generate`/`generateStream`, so pre-call failures (capability mismatch, registry miss) bypassed the analytics record. The class doc promises "both successful and failed calls land in the table for billing visibility" — broken by these paths. Moved resolution into the try block; failures now record with `backend: 'unknown'` when the registry missed entirely, or with the backend's name when it was found but the capability didn't match. `error_code` is `'backend_not_found'`, `'capability_unsupported'`, or `'pending_unsupported'` as appropriate. **F3 (Important) — Context type drift.** Added `handlerPath?: string` and `signal?: AbortSignal` to the `Context` interface in `resources/ResourceInterface.ts`. These fields existed on the `Request` object that becomes the ALS-bound Context for HTTP/WS paths (via `transaction(request, ...)` at `REST.ts:92, 368`), but weren't declared on the Context interface — the facade was reading them via an `as any` cast. Drift bug latent until the next refactor; documenting them on Context makes the contract explicit and lets us drop the `as any` cast in `Models.ts`. **F4 (Important) — Pending result duplicate row.** When a backend returned `{ status: 'pending' }`, the facade wrote a success row via `#record(...)`, THEN `unwrap()` threw inside the same try and the catch wrote a second (failure) row. Replaced `unwrap()` with an inline status check that throws BEFORE recording; pending now produces exactly one row with `success: false` and `error_code: 'pending_unsupported'`. Latent today (no Phase 1 backend emits pending) but ships in the contract. **F5 (Important) — `isReadOnlyMode()` gate.** `flush()` and `cleanup()` now short-circuit on `isReadOnlyMode()`, mirroring `resources/analytics/write.ts:643-650`. Without this, read-only nodes would log put/remove failures every 10s indefinitely. **F6 (Important) — Async put rejection logging.** `Promise.allSettled` was awaited but its results were discarded — async LMDB rejections were silently swallowed (sync throws were already caught and logged per-record). Iterate the settled results and `log.warn` on each rejection so billing-visible record losses are observable. **F13 (Important) — Stream completion flag.** `#wrapStream`'s `finally` block recorded `success=true` whenever no exception fired — including when the consumer `break`s the for-await loop early. Added a `completed` flag set only after the inner for-await exits normally; early termination now records `success=false` with `error_code: 'aborted'`. The model did real work the caller didn't consume; logging that as success poisoned billing aggregates. **F11 (Nice-to-have) — Remove unregistered config key.** The writer called `envGet('analytics.modelCallRetentionDays')` to override retention, but the key isn't registered in `CONFIG_PARAM_MAP`, so `getConfigValue` silently returned undefined. Removed the dead envGet call; 90-day default is hardcoded with a TODO comment noting that operator-tunable retention will land in Phase 2 alongside the YAML→registry bootstrapper that owns models.* config. **F12 (Nice-to-have) — id spread order.** Flipped `{ id: ..., ...record }` to `{ ...record, id: getNextMonotonicTime() }` so a record that accidentally carries an `id` field can't override the monotonic primary key. Defense-in-depth; no current caller hits this. Test updates: - New: capability error writes a record (F2). - New: pending result writes exactly ONE record (F4 — was silently a double-write before). - New: ModelBackendNotFoundError records with backend='unknown' (F2). - New: stream consumer `break` records success=false + 'aborted' (F13). - New: pre-call generateStream failure records (F2). - New: async put rejection (Promise.reject from primaryStore.put) doesn't silently swallow remaining records (F6). - Existing tests updated where contract widened (capability test now asserts both the throw AND the record). Coverage on `resources/models/`: 94.5% statements / 95.2% branches (up from 94/92.6). Models.ts at 100% statements. All 19 Models.test + 12 analyticsTable.test + 8 backendRegistry.test + 11 TestBackend.test pass. NOT addressed in this commit (deferred): - F7 PARTIAL (concurrent-flush backpressure hardening). Best-effort posture acknowledged in file header; matches analytics/write.ts. - F8 NOISE (stale _table cache). Matches getRawAnalyticsTable pattern; system-DB recycling not a supported runtime op. - F9 NOISE (eager singleton intervals). .unref()'d; matches existing analytics writer. - F10 NOISE (async-gen abandonment without .return()). JS-spec violation by consumer; for-await consumers are safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27a02af commit fabef73

6 files changed

Lines changed: 179 additions & 45 deletions

File tree

resources/ResourceInterface.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ export interface Context {
9898
_freezeRecords?: boolean; // until v5, we conditionally freeze records for back-compat
9999
timestamp?: number;
100100
includeExpensiveRecordCountEstimates?: boolean;
101+
/**
102+
* Matched route path of the calling Resource, populated by the HTTP/WS entry points
103+
* before they hand the request into `transaction()`. Populated on the Request that
104+
* becomes the ALS-bound Context for that path; absent for ops-API, internal jobs,
105+
* and replication-driven contexts.
106+
*/
107+
handlerPath?: string;
108+
/**
109+
* Abort signal carried through ALS so generator bodies can forward cancellation to
110+
* external work (e.g. `scope.models.generateStream({ signal })`). Populated on the
111+
* Request that becomes the ALS-bound Context for HTTP/WS paths via #513.
112+
*/
113+
signal?: AbortSignal;
101114
}
102115

103116
export interface SourceContext<TRequestContext = Context, Record extends object = any> {

resources/databases.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const NON_REPLICATING_SYSTEM_TABLES = [
7878
'hdb_temp',
7979
'hdb_certificate',
8080
'hdb_raw_analytics',
81+
'hdb_model_calls',
8182
'hdb_session_will',
8283
'hdb_job',
8384
'hdb_info',

resources/models/Models.ts

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ type CallMethod = ModelCallRecord['method'];
2222
* Public `scope.models` facade. One instance per `Scope`.
2323
*
2424
* On every call:
25-
* - Resolves the configured backend via `backendRegistry` (config-driven).
25+
* - Resolves the configured backend via `backendRegistry`.
2626
* - Reads the ALS-bound request `Context` to extract accounting context
2727
* (tenantId, handlerPath) and an `AbortSignal`. Outside an ALS scope
2828
* (app-init, internal jobs), accounting is empty and signal is undefined.
2929
* - Records the call to `hdb_model_calls` via the buffered writer — both
3030
* successful and failed calls land in the table for billing visibility.
31+
* Pre-call resolution / capability errors land too, with `backend: 'unknown'`.
3132
*
3233
* The ALS pattern matches `resources/Table.ts:3517` and is rooted at
3334
* `resources/transaction.ts:6`.
@@ -40,68 +41,88 @@ export class Models implements ModelsContract {
4041
}
4142

4243
async embed(input: string | string[], opts: EmbedOpts = {}): Promise<Float32Array[]> {
43-
const backend = resolveEmbedding(opts.model);
44-
requireCapability(backend, 'embed');
4544
const { accounting, signal } = resolveCallContext(opts.signal);
46-
const backendOpts: BackendOpts<EmbedOpts> = { ...opts, signal, accounting };
4745
const startedAt = performance.now();
46+
let backend: ModelBackend | undefined;
4847
try {
48+
backend = resolveEmbedding(opts.model);
49+
requireCapability(backend, 'embed');
50+
const backendOpts: BackendOpts<EmbedOpts> = { ...opts, signal, accounting };
4951
const result = await backend.embed!(input, backendOpts);
52+
// Throw on `pending` BEFORE recording success — otherwise we'd write a
53+
// success row followed by a failure row from the catch (duplicate).
54+
if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
5055
this.#record(backend, 'embed', opts.model, accounting, undefined, result, startedAt);
51-
return unwrap(backend, result);
56+
return result.output;
5257
} catch (err) {
5358
this.#recordFailure(backend, 'embed', opts.model, accounting, undefined, startedAt, err);
5459
throw err;
5560
}
5661
}
5762

5863
async generate(input: GenerateInput, opts: GenerateOpts = {}): Promise<GenerateResult> {
59-
const backend = resolveGenerative(opts.model);
60-
requireCapability(backend, 'generate');
6164
const { accounting, signal } = resolveCallContext(opts.signal);
62-
const backendOpts: BackendOpts<GenerateOpts> = { ...opts, signal, accounting };
6365
const startedAt = performance.now();
66+
let backend: ModelBackend | undefined;
6467
try {
68+
backend = resolveGenerative(opts.model);
69+
requireCapability(backend, 'generate');
70+
const backendOpts: BackendOpts<GenerateOpts> = { ...opts, signal, accounting };
6571
const result = await backend.generate!(input, backendOpts);
72+
if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
6673
this.#record(backend, 'generate', opts.model, accounting, opts, result, startedAt);
67-
return unwrap(backend, result);
74+
return result.output;
6875
} catch (err) {
6976
this.#recordFailure(backend, 'generate', opts.model, accounting, opts, startedAt, err);
7077
throw err;
7178
}
7279
}
7380

7481
generateStream(input: GenerateInput, opts: GenerateOpts = {}): AsyncIterable<GenerateChunk> {
75-
const backend = resolveGenerative(opts.model);
76-
requireCapability(backend, 'stream');
7782
const { accounting, signal } = resolveCallContext(opts.signal);
83+
const startedAt = performance.now();
84+
let backend: ModelBackend;
85+
try {
86+
backend = resolveGenerative(opts.model);
87+
requireCapability(backend, 'stream');
88+
} catch (err) {
89+
// Record pre-call failure synchronously so callers that hold but never
90+
// iterate the returned iterable still produce a billing row, then rethrow.
91+
this.#recordFailure(undefined, 'generateStream', opts.model, accounting, opts, startedAt, err);
92+
throw err;
93+
}
7894
const backendOpts: BackendOpts<GenerateOpts> = { ...opts, signal, accounting };
79-
return this.#wrapStream(backend, input, backendOpts, opts, accounting);
95+
return this.#wrapStream(backend, input, backendOpts, opts, accounting, startedAt);
8096
}
8197

8298
async *#wrapStream(
8399
backend: ModelBackend,
84100
input: GenerateInput,
85101
backendOpts: BackendOpts<GenerateOpts>,
86102
opts: GenerateOpts,
87-
accounting: AccountingContext
103+
accounting: AccountingContext,
104+
startedAt: number
88105
): AsyncIterable<GenerateChunk> {
89-
const startedAt = performance.now();
90-
let success = true;
91106
let caught: unknown;
107+
let completed = false;
92108
try {
93109
for await (const chunk of backend.generateStream!(input, backendOpts)) {
94110
yield chunk;
95111
}
112+
completed = true;
96113
} catch (err) {
97-
success = false;
98114
caught = err;
99115
throw err;
100116
} finally {
101-
if (success) {
117+
if (completed) {
102118
this.#record(backend, 'generateStream', opts.model, accounting, opts, undefined, startedAt);
103-
} else {
119+
} else if (caught) {
104120
this.#recordFailure(backend, 'generateStream', opts.model, accounting, opts, startedAt, caught);
121+
} else {
122+
// Stream terminated by the consumer (break / iter.return()) without an error
123+
// from the backend. Treat as an aborted call rather than success — the model
124+
// did real work that the caller didn't consume.
125+
this.#recordFailure(backend, 'generateStream', opts.model, accounting, opts, startedAt, 'aborted');
105126
}
106127
}
107128
}
@@ -120,23 +141,24 @@ export class Models implements ModelsContract {
120141
}
121142

122143
#recordFailure(
123-
backend: ModelBackend,
144+
backend: ModelBackend | undefined,
124145
method: CallMethod,
125146
model: string | undefined,
126147
accounting: AccountingContext,
127148
opts: GenerateOpts | undefined,
128149
startedAt: number,
129-
err: unknown
150+
errOrCode: unknown
130151
): void {
152+
const error_code = typeof errOrCode === 'string' ? errOrCode : classifyError(errOrCode);
131153
this.#analyticsWriter.write({
132154
...buildRecord(backend, method, model, accounting, opts, undefined, startedAt, false),
133-
error_code: classifyError(err),
155+
error_code,
134156
});
135157
}
136158
}
137159

138160
function buildRecord(
139-
backend: ModelBackend,
161+
backend: ModelBackend | undefined,
140162
method: CallMethod,
141163
model: string | undefined,
142164
accounting: AccountingContext,
@@ -146,7 +168,7 @@ function buildRecord(
146168
success: boolean
147169
): ModelCallRecord {
148170
const record: ModelCallRecord = {
149-
backend: backend.name,
171+
backend: backend?.name ?? 'unknown',
150172
method,
151173
model,
152174
tenant: accounting.tenantId,
@@ -166,7 +188,7 @@ function buildRecord(
166188
}
167189

168190
function resolveCallContext(callerSignal?: AbortSignal): { accounting: AccountingContext; signal?: AbortSignal } {
169-
const ctx = contextStorage.getStore() as any;
191+
const ctx = contextStorage.getStore();
170192
return {
171193
accounting: {
172194
tenantId: extractTenantId(ctx?.user),
@@ -184,16 +206,13 @@ function requireCapability(backend: ModelBackend, capability: 'embed' | 'generat
184206
if (!backend.capabilities()[capability]) throw new ModelCapabilityError(backend.name, capability);
185207
}
186208

187-
function unwrap<T>(backend: ModelBackend, result: ModelCallResult<T>): T {
188-
if (result.status === 'completed') return result.output;
189-
throw new ModelPendingNotSupportedError(backend.name);
190-
}
191-
192209
function classifyError(err: unknown): string {
193210
if (err && typeof err === 'object') {
194211
const e = err as { name?: string; code?: string };
195212
if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return 'aborted';
196213
if (e.name === 'ModelCapabilityError') return 'capability_unsupported';
214+
if (e.name === 'ModelBackendNotFoundError') return 'backend_not_found';
215+
if (e.name === 'ModelPendingNotSupportedError') return 'pending_unsupported';
197216
}
198217
return 'backend_error';
199218
}

resources/models/analyticsTable.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { table } from '../databases.ts';
2-
import { get as envGet } from '../../utility/environment/environmentManager.ts';
1+
import { table, isReadOnlyMode } from '../databases.ts';
32
import { getNextMonotonicTime } from '../../utility/lmdb/commonUtility.ts';
43
import harperLogger from '../../utility/logging/harper_logger.ts';
54

@@ -8,7 +7,11 @@ const log = harperLogger.forComponent('models').conditional;
87
const DEFAULT_FLUSH_INTERVAL_MS = 10_000; // 10s
98
const DEFAULT_MAX_BUFFER_SIZE = 1000;
109
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1h
11-
const DEFAULT_RETENTION_DAYS = 90;
10+
// 90-day default tuned for billing windows. Operator-tunable config key will land
11+
// in Phase 2 alongside the YAML→registry bootstrapper (Harper's `getConfigValue`
12+
// only reads keys registered in `CONFIG_PARAM_MAP`, so we defer config plumbing
13+
// until the first real backend ships and the key has a documented owner).
14+
const DEFAULT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
1215

1316
/**
1417
* One row in `hdb_model_calls`. Field names are snake_case to match the table
@@ -109,7 +112,7 @@ export class ModelCallAnalyticsWriter {
109112
const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
110113
const cleanupIntervalMs = opts.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
111114
this.#maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
112-
this.#retentionMs = opts.retentionMs ?? resolveRetentionMs();
115+
this.#retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
113116
this.#getTable = opts.getTable ?? getModelCallsTable;
114117
this.#flushTimer = setInterval(() => {
115118
this.flush().catch((err) => log.warn?.(`Model-call analytics flush failed: ${err?.message ?? err}`));
@@ -123,7 +126,9 @@ export class ModelCallAnalyticsWriter {
123126

124127
write(record: ModelCallRecord): void {
125128
if (this.#stopped) return;
126-
this.#buffer.push({ id: getNextMonotonicTime(), ...record });
129+
// id last so a record that accidentally carries an `id` field can't override
130+
// the monotonic primary key.
131+
this.#buffer.push({ ...record, id: getNextMonotonicTime() });
127132
if (this.#buffer.length >= this.#maxBufferSize) {
128133
// Out-of-cadence flush; swallow errors so a failing flush doesn't escape into the caller.
129134
this.flush().catch((err) => log.warn?.(`Model-call analytics flush failed: ${err?.message ?? err}`));
@@ -132,26 +137,40 @@ export class ModelCallAnalyticsWriter {
132137

133138
async flush(): Promise<void> {
134139
if (this.#buffer.length === 0) return;
140+
// Read-only nodes (followers, recovery mode) shouldn't accumulate doomed writes —
141+
// drop the buffer and skip. Matches `resources/analytics/write.ts:643-650`.
142+
if (isReadOnlyMode()) {
143+
this.#buffer = [];
144+
return;
145+
}
135146
const batch = this.#buffer;
136147
this.#buffer = [];
137148
const tbl = this.#getTable();
138-
const puts: Promise<unknown>[] = [];
149+
const puts: Array<{ id: number; promise: Promise<unknown> }> = [];
139150
for (const record of batch) {
140151
try {
141152
const result = tbl.primaryStore.put(record.id, record);
142153
if (result && typeof (result as { then?: unknown }).then === 'function') {
143-
puts.push(result as Promise<unknown>);
154+
puts.push({ id: record.id, promise: result as Promise<unknown> });
144155
}
145156
} catch (err) {
146157
log.warn?.(`Model-call analytics put failed for id=${record.id}: ${(err as Error)?.message ?? err}`);
147158
}
148159
}
149160
if (puts.length > 0) {
150-
await Promise.allSettled(puts);
161+
const results = await Promise.allSettled(puts.map((p) => p.promise));
162+
for (let i = 0; i < results.length; i++) {
163+
const r = results[i];
164+
if (r.status === 'rejected') {
165+
const reason = r.reason as { message?: string } | undefined;
166+
log.warn?.(`Model-call analytics async put failed for id=${puts[i].id}: ${reason?.message ?? r.reason}`);
167+
}
168+
}
151169
}
152170
}
153171

154172
async cleanup(): Promise<void> {
173+
if (isReadOnlyMode()) return;
155174
const end = Date.now() - this.#retentionMs;
156175
const tbl = this.#getTable();
157176
for (const key of tbl.primaryStore.getKeys({ start: false, end })) {
@@ -172,12 +191,6 @@ export class ModelCallAnalyticsWriter {
172191
}
173192
}
174193

175-
function resolveRetentionMs(): number {
176-
const days = envGet('analytics.modelCallRetentionDays');
177-
const n = typeof days === 'number' && days > 0 ? days : DEFAULT_RETENTION_DAYS;
178-
return n * 24 * 60 * 60 * 1000;
179-
}
180-
181194
let _writer: ModelCallAnalyticsWriter | undefined;
182195
/** Process-wide singleton writer. Constructed on first access. */
183196
export function getModelCallAnalyticsWriter(): ModelCallAnalyticsWriter {

unitTests/resources/models/Models.test.js

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,19 @@ describe('Models facade', () => {
113113
assert.strictEqual(seenSignal, ctxSignal);
114114
});
115115

116-
it('throws ModelCapabilityError when the backend does not support embed', async () => {
116+
it('throws ModelCapabilityError when the backend does not support embed AND records the failure', async () => {
117117
setEmbedding('default', {
118118
name: 'no-embed',
119119
capabilities: () => ({ embed: false, generate: true, stream: false, tools: false, adapters: false }),
120120
});
121121
await assert.rejects(() => models.embed('x'), ModelCapabilityError);
122+
assert.strictEqual(writer.records.length, 1);
123+
assert.strictEqual(writer.records[0].backend, 'no-embed');
124+
assert.strictEqual(writer.records[0].success, false);
125+
assert.strictEqual(writer.records[0].error_code, 'capability_unsupported');
122126
});
123127

124-
it('throws ModelPendingNotSupportedError when backend returns pending', async () => {
128+
it('writes ONE record with success=false on pending result (not duplicate from unwrap+catch)', async () => {
125129
setEmbedding('default', {
126130
name: 'pending',
127131
capabilities: () => ({ embed: true, generate: false, stream: false, tools: false, adapters: false }),
@@ -130,6 +134,19 @@ describe('Models facade', () => {
130134
},
131135
});
132136
await assert.rejects(() => models.embed('x'), ModelPendingNotSupportedError);
137+
assert.strictEqual(writer.records.length, 1, 'pending result must not produce duplicate rows');
138+
assert.strictEqual(writer.records[0].success, false);
139+
assert.strictEqual(writer.records[0].error_code, 'pending_unsupported');
140+
});
141+
142+
it("records pre-call ModelBackendNotFoundError with backend='unknown' and error_code='backend_not_found'", async () => {
143+
// No backend mapped for the requested logical name.
144+
await assert.rejects(() => models.embed('x', { model: 'no-such-name' }));
145+
assert.strictEqual(writer.records.length, 1);
146+
assert.strictEqual(writer.records[0].backend, 'unknown');
147+
assert.strictEqual(writer.records[0].model, 'no-such-name');
148+
assert.strictEqual(writer.records[0].success, false);
149+
assert.strictEqual(writer.records[0].error_code, 'backend_not_found');
133150
});
134151

135152
it('writes an analytics record with success=false and sanitized error_code on backend failure', async () => {
@@ -225,5 +242,38 @@ describe('Models facade', () => {
225242
assert.strictEqual(writer.records[0].success, false);
226243
assert.strictEqual(writer.records[0].error_code, 'backend_error');
227244
});
245+
246+
it("records success=false with error_code='aborted' when the consumer breaks early", async () => {
247+
setGenerative('default', {
248+
name: 'long-stream',
249+
capabilities: () => ({ embed: false, generate: true, stream: true, tools: false, adapters: false }),
250+
async *generateStream() {
251+
yield { deltaContent: 'one ' };
252+
yield { deltaContent: 'two ' };
253+
yield { deltaContent: 'three ' };
254+
yield { finishReason: 'stop' };
255+
},
256+
});
257+
let count = 0;
258+
for await (const _chunk of models.generateStream('x')) {
259+
if (++count === 2) break;
260+
}
261+
assert.strictEqual(writer.records.length, 1);
262+
assert.strictEqual(writer.records[0].success, false);
263+
assert.strictEqual(writer.records[0].error_code, 'aborted');
264+
});
265+
266+
it("records pre-call failure with backend='unknown' when the generative backend isn't registered", async () => {
267+
await assert.rejects(async () => {
268+
// eslint-disable-next-line no-unused-vars
269+
for await (const _ of models.generateStream('x', { model: 'no-such-name' })) {
270+
// will not iterate — resolve throws first
271+
}
272+
});
273+
assert.strictEqual(writer.records.length, 1);
274+
assert.strictEqual(writer.records[0].backend, 'unknown');
275+
assert.strictEqual(writer.records[0].error_code, 'backend_not_found');
276+
assert.strictEqual(writer.records[0].method, 'generateStream');
277+
});
228278
});
229279
});

0 commit comments

Comments
 (0)