Skip to content

Commit b976fa3

Browse files
refactor!: replace ModelRequestError with EmbeddingRequestError (#10)
* refactor!: replace ModelRequestError with EmbeddingRequestError Every failure, including a malformed or short reply and an empty probe, throws EmbeddingRequestError carrying the classified InferenceError. ModelRequestError and the runJSONRequest/extractRetryAfterMs exports are gone; RequestDependencies and RetryAfterExtractor are exported as the types of EmbedOptions fields. Options are optional, with deps defaulting to global fetch and createDefaultScheduler(). Config is asserted against EmbedConfigSchema, replacing the hand-written batchSize RangeError, and request objects are explicit literals instead of conditional spreads. Also closes CL-9059, CL-9050, CL-9048, CL-9054, CL-9056. * refactor: resolve request defaults once in embedTexts
1 parent 4f9578b commit b976fa3

7 files changed

Lines changed: 138 additions & 159 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,31 @@ Callers import from `@corbits/embedding`. The barrel is the public surface:
1313
| `embedTexts` | Batch texts, post sequentially, return `number[][]` in input order |
1414
| `probeEmbedDims` | Embed one probe string; report the vector length that came back |
1515
| `EmbedConfig` / `EmbedConfigSchema` | Endpoint, model, and optional knobs |
16-
| `EmbedOptions` | Harness deps plus optional retry, `Retry-After` override, abort |
17-
| `runJSONRequest` | Classified, retried JSON POST (sibling clients reuse this) |
18-
| `extractRetryAfterMs` | Default `Retry-After` reader (seconds or HTTP-date) |
19-
| `ModelRequestError` | Transport, HTTP, or non-JSON 200 — `reason` is the classified `InferenceError` |
16+
| `EmbedOptions` | Optional harness deps, retry, `Retry-After` override, abort |
17+
| `RequestDependencies` / `RetryAfterExtractor` | Types of the `EmbedOptions` fields |
18+
| `EmbeddingRequestError` | Every request failure — `reason` is the classified `InferenceError` |
19+
20+
The JSON transport (`runJSONRequest`) is private.
2021

2122
Quickstart shape (matches the README):
2223

2324
```ts
24-
import { createDefaultScheduler } from "@intx/inference";
25-
import { embedTexts, probeEmbedDims } from "@corbits/embedding";
26-
27-
const deps = { fetch, scheduler: createDefaultScheduler() };
28-
const [vector] = await embedTexts(
29-
["hello world"],
30-
{ baseURL: "http://localhost:11434/v1", model: "nomic-embed-text" },
31-
{ deps },
32-
);
25+
import { embedTexts } from "@corbits/embedding";
26+
27+
const [vector] = await embedTexts(["hello world"], {
28+
baseURL: "http://localhost:11434/v1",
29+
model: "nomic-embed-text",
30+
});
3331
```
3432

35-
`probeEmbedDims(config, { deps })` is the same config and deps; it is not a
33+
`probeEmbedDims(config)` is the same config and options; it is not a
3634
separate protocol.
3735

3836
## Request dependencies
3937

4038
The transport reads only `fetch` and `scheduler` from the harness
41-
(`RequestDependencies`). That is deliberately narrower than Interchange
39+
(`RequestDependencies`), defaulting to global `fetch` and
40+
`createDefaultScheduler()`. That is deliberately narrower than Interchange
4241
`Dependencies`, whose `adapters` registry a caller should not have to
4342
assemble in order to embed a string. A real `Dependencies` still satisfies
4443
the pick structurally.
@@ -88,20 +87,22 @@ and `createDefaultRetryPolicy` (back off retryables, abort the rest). A 429
8887
is therefore classified and backed off as a chat 429 would be; a
8988
`credential_failure` aborts immediately.
9089

91-
`runJSONRequest` is duplicated, modulo comments, in `@corbits/reranking`. It
92-
is not shared infrastructure yet — the intended home is `@intx/inference` as
93-
a non-streaming sibling of `runInference`. Until then, `ModelRequestError` is
94-
a distinct class in each package: `instanceof` does not hold across the two.
95-
Catch on `error.name === "ModelRequestError"`.
90+
Following Interchange, the `InferenceError` is carried as data on a thrown
91+
`Error` subclass (`EmbeddingRequestError`), never thrown itself.
92+
93+
`runJSONRequest` is duplicated, modulo comments, in `@corbits/reranking`,
94+
which still throws its own `ModelRequestError`; code using both packages
95+
checks two error classes. The intended home for the transport is
96+
`@intx/inference`, as a non-streaming sibling of `runInference`.
9697

9798
## Failure modes
9899

99100
- Transport, HTTP status, or a 200 whose body is not JSON →
100-
`ModelRequestError` with classified `reason` and the URL.
101-
- Malformed embeddings JSON, wrong count, bad index → thrown `Error` (not
102-
`ModelRequestError`); the protocol succeeded, the payload did not.
103-
- `batchSize` not an integer `>= 1` → `RangeError` before any request
104-
(`i += size` of 0 would never advance).
101+
`EmbeddingRequestError` with classified `reason` and the URL.
102+
- Malformed embeddings JSON, wrong count, bad index →
103+
`EmbeddingRequestError` with a `classifyProtocolMismatch` reason.
104+
- Config failing `EmbedConfigSchema` (e.g. `batchSize` not an integer
105+
`>= 1`) → arktype `TraversalError` before any request.
105106
- Empty `texts` → `[]`, no request.
106107
- Caller `signal` or per-attempt timeout (default 30s) abort the attempt;
107108
aborting mid-delay wakes immediately and the next attempt fails its

‎IMPLEMENTATION.md‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ Reply: `{ data: { index: number, embedding: number[] | string }[] }`.
8080
Base64 strings decode with `atob` → `Uint8Array` → little-endian
8181
`Float32Array` → `number[]`.
8282

83-
`embedTexts` also range-checks `batchSize` at runtime so a value that
84-
bypassed the schema cannot stall `batches` (`i += size`).
83+
`embedTexts` asserts `config` against `EmbedConfigSchema` first, so a
84+
`batchSize` below 1 cannot stall `batches` (`i += size`).
8585

8686
## Errors and retry
8787

@@ -92,16 +92,13 @@ bypassed the schema cannot stall `batches` (`i += size`).
9292
3. network throw → `classifyNetworkError` or `classifyAbortError`
9393
4. JSON parse fail → `classifyProtocolMismatch`
9494
5. `retryPolicy` (default `createDefaultRetryPolicy`) → `abort` throws
95-
`ModelRequestError`, else sleep `delayMs` on `deps.scheduler`
95+
`EmbeddingRequestError`, else sleep `delayMs` on `deps.scheduler`
9696

9797
`extractRetryAfterMs` reads `Retry-After` as seconds or HTTP-date, clamped
9898
at zero. Callers override via `EmbedOptions.extractRetryAfterMs` (same
9999
signature as Interchange's unexported extractor; re-declared so this
100100
package depends only on the published surface).
101101

102-
`ModelRequestError.name` is `"ModelRequestError"`. Discriminate on the
103-
name when catching both this package and `@corbits/reranking`.
104-
105102
## Tests
106103

107104
`bun run test` is `bun test ./src`. Coverage is the client contract: empty

‎README.md‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,7 @@ scheduler), with optional `retryPolicy`, `extractRetryAfterMs`, and `signal`.
7373

7474
## Errors
7575

76-
Every failure mode — transport, HTTP status, or a 200 with an unexpected body — raises `ModelRequestError`, carrying the classified `InferenceError` as `reason` plus the request URL. The embedding and reranking packages each carry their own copy of this class while the shared transport is upstreamed, so code catching both discriminates on `error.name === "ModelRequestError"`.
77-
78-
## Lower-level: transport
79-
80-
The barrel also re-exports the one-shot JSON transport `embedTexts` is built on, for sibling clients that want the same classified, retried request path:
81-
82-
- `runJSONRequest` — one JSON POST with error classification and retry.
83-
- `extractRetryAfterMs` — default `Retry-After` reader, overridable per call.
84-
- `ModelRequestError` — error for every failure mode, with `reason` and URL.
85-
- Types `RunRequestOptions` and `RetryAfterExtractor`.
76+
Every request failure — transport, HTTP status, or a 200 with an unexpected body — throws `EmbeddingRequestError` (`extends Error`), carrying the classified `InferenceError` as `reason` and the request `url`. A config that fails `EmbedConfigSchema` throws arktype's `TraversalError` before any request.
8677

8778
## Interchange
8879

‎src/embed.test.ts‎

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
EmbedConfigSchema,
99
type EmbedConfig,
1010
} from "./embed";
11-
import { extractRetryAfterMs, ModelRequestError } from "./request";
11+
import { EmbeddingRequestError } from "./request";
1212

1313
const CONFIG: EmbedConfig = {
1414
baseURL: "https://embed.example/v1",
@@ -134,12 +134,12 @@ describe("embedTexts", () => {
134134
it("rejects a batchSize that is not an integer >= 1 instead of looping forever", async () => {
135135
// `batches` advances by `i += size`, so a size of 0 never advances and
136136
// grows the batch list until OOM. Fractional sizes would also stall or
137-
// slice wrongly. Schema and runtime both require integer >= 1.
137+
// slice wrongly. The schema requires integer >= 1.
138138
for (const batchSize of [0, -1, 0.5, Number.NaN, 1.5]) {
139139
const { deps: d, calls } = deps([embedding(3)]);
140140
await expect(
141141
embedTexts(["a"], { ...CONFIG, batchSize }, { deps: d }),
142-
).rejects.toBeInstanceOf(RangeError);
142+
).rejects.toThrow(/batchSize/);
143143
expect(calls).toHaveLength(0);
144144
}
145145
});
@@ -148,9 +148,9 @@ describe("embedTexts", () => {
148148
const { deps: d } = deps([
149149
Response.json({ data: [{ index: 0, embedding: true }] }),
150150
]);
151-
await expect(embedTexts(["a"], CONFIG, { deps: d })).rejects.toThrow(
152-
/malformed embeddings response/,
153-
);
151+
const rejection = expect(embedTexts(["a"], CONFIG, { deps: d })).rejects;
152+
await rejection.toBeInstanceOf(EmbeddingRequestError);
153+
await rejection.toThrow(/malformed embeddings response/);
154154
});
155155
});
156156

@@ -195,7 +195,7 @@ describe("retry behaviour inherited from the inference policy", () => {
195195
new Response("unauthorized", { status: 401 }),
196196
]);
197197
await expect(embedTexts(["a"], CONFIG, { deps: d })).rejects.toBeInstanceOf(
198-
ModelRequestError,
198+
EmbeddingRequestError,
199199
);
200200
expect(calls).toHaveLength(1);
201201
});
@@ -232,21 +232,6 @@ describe("retry-after handling", () => {
232232
});
233233
expect(seen).toEqual(["7"]);
234234
});
235-
236-
it("reads the seconds form and tolerates an absent header", () => {
237-
expect(extractRetryAfterMs(new Headers({ "retry-after": "2" }))).toBe(
238-
2_000,
239-
);
240-
expect(extractRetryAfterMs(new Headers())).toBeUndefined();
241-
});
242-
243-
it("never returns a negative delay for a date already past", () => {
244-
expect(
245-
extractRetryAfterMs(
246-
new Headers({ "retry-after": "Wed, 21 Oct 2015 07:28:00 GMT" }),
247-
),
248-
).toBeGreaterThanOrEqual(0);
249-
});
250235
});
251236

252237
describe("reply integrity", () => {
@@ -319,7 +304,7 @@ describe("transport failure modes", () => {
319304
}),
320305
]);
321306
await expect(embedTexts(["a"], CONFIG, { deps: d })).rejects.toBeInstanceOf(
322-
ModelRequestError,
307+
EmbeddingRequestError,
323308
);
324309
});
325310

@@ -347,7 +332,7 @@ describe("transport failure modes", () => {
347332
{ ...CONFIG, timeoutMs: 5 },
348333
{ deps: d, signal: controller.signal },
349334
),
350-
).rejects.toBeInstanceOf(ModelRequestError);
335+
).rejects.toBeInstanceOf(EmbeddingRequestError);
351336
expect(calls).toHaveLength(1);
352337
});
353338
});

0 commit comments

Comments
 (0)