Skip to content

Commit df54e86

Browse files
fix: cap Retry-After at 60 seconds and trim trailing slashes from baseURL (#19)
1 parent a35e870 commit df54e86

5 files changed

Lines changed: 59 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,24 +53,24 @@ console.log(vectors.length, vectors[0]?.length); // 2 768
5353

5454
### Config
5555

56-
| Field | Type | Description |
57-
| ---------------- | ---------------------- | ------------------------------------------------------------------------------------------- |
58-
| `baseURL` | `string` | Server root with its version path, such as `http://host:11434/v1`. |
59-
| `model` | `string` | Model name. |
60-
| `apiKey` | `string?` | Sent as a bearer token. |
61-
| `dimensions` | `number?` | Requested output dimension. Sent only when set; a reply of any other dimension is rejected. |
62-
| `encodingFormat` | `"float" \| "base64"?` | Wire format. Defaults to `float`. Results are always `number[]`. |
63-
| `batchSize` | `number?` | Texts per request. Defaults to 32. |
64-
| `timeoutMs` | `number?` | Per-attempt timeout; a timed-out attempt is retried. Defaults to 30000. |
56+
| Field | Type | Description |
57+
| ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------ |
58+
| `baseURL` | `string` | Server root with its version path, such as `http://host:11434/v1`. Trailing slashes are ignored. |
59+
| `model` | `string` | Model name. |
60+
| `apiKey` | `string?` | Sent as a bearer token. |
61+
| `dimensions` | `number?` | Requested output dimension. Sent only when set; a reply of any other dimension is rejected. |
62+
| `encodingFormat` | `"float" \| "base64"?` | Wire format. Defaults to `float`. Results are always `number[]`. |
63+
| `batchSize` | `number?` | Texts per request. Defaults to 32. |
64+
| `timeoutMs` | `number?` | Per-attempt timeout; a timed-out attempt is retried. Defaults to 30000. |
6565

6666
### Options
6767

68-
| Field | Description |
69-
| --------------------- | ------------------------------------------------------------------------------- |
70-
| `deps` | `{ fetch, scheduler }`. Defaults to global `fetch` and Interchange's scheduler. |
71-
| `retryPolicy` | Retry policy. Defaults to Interchange's policy. |
72-
| `extractRetryAfterMs` | Reads the server's retry delay. Defaults to parsing `Retry-After`. |
73-
| `signal` | Aborts all pending requests. |
68+
| Field | Description |
69+
| --------------------- | ---------------------------------------------------------------------------------------- |
70+
| `deps` | `{ fetch, scheduler }`. Defaults to global `fetch` and Interchange's scheduler. |
71+
| `retryPolicy` | Retry policy. Defaults to Interchange's policy. |
72+
| `extractRetryAfterMs` | Reads the server's retry delay. Defaults to parsing `Retry-After`, capped at 60 seconds. |
73+
| `signal` | Aborts all pending requests. |
7474

7575
### Errors
7676

‎e2e/retry.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,16 @@ test("reports a caller abort as aborted, without retrying", async () => {
165165
reason: { category: "aborted" },
166166
});
167167
});
168+
169+
test("caps a Retry-After at 60 seconds", async () => {
170+
harness = setupHarness({ enableInferenceTimers: true });
171+
reply(harness, "rate limited", 429, { "retry-after": "86400" });
172+
reply(harness, JSON.stringify({ data: [{ index: 0, embedding: [1] }] }), 200);
173+
174+
const pending = embedTexts(["a"], CONFIG, { deps: harness.deps });
175+
await harness.run();
176+
177+
expect(await pending).toEqual([[1]]);
178+
expect(harness.clock.now()).toBeGreaterThanOrEqual(60_000);
179+
expect(harness.clock.now()).toBeLessThan(61_000);
180+
});

‎e2e/wire.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ test("sends only configured fields to {baseURL}/embeddings", async () => {
3737
expect(await run(harness, pending)).toEqual([[1]]);
3838
});
3939

40+
test("trims trailing slashes from baseURL", async () => {
41+
harness = setupHarness();
42+
const stream = harness.scenario.createStream();
43+
harness.scenario.whenRequestMatches(
44+
(req) => req.url === "https://embed.example/v1/embeddings",
45+
stream,
46+
);
47+
stream.enqueueAll(
48+
[new TextEncoder().encode('{"data":[{"index":0,"embedding":[1]}]}')],
49+
{ startAt: 1 },
50+
);
51+
const pending = embedTexts(
52+
["a"],
53+
{ ...CONFIG, baseURL: "https://embed.example/v1//" },
54+
{ deps: harness.deps },
55+
);
56+
expect(await run(harness, pending)).toEqual([[1]]);
57+
});
58+
4059
test("sends a bearer token only when apiKey is set", async () => {
4160
harness = setupHarness();
4261
const stream = harness.scenario.createStream();

‎src/embed.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function buildRequest(
8686
}
8787

8888
return {
89-
url: `${config.baseURL}/embeddings`,
89+
url: `${config.baseURL.replace(/\/+$/, "")}/embeddings`,
9090
headers,
9191
// Unset `dimensions` and `encoding_format` are dropped by JSON.stringify.
9292
body: JSON.stringify({

‎src/request.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,18 @@ type Attempt =
6363

6464
export const DEFAULT_TIMEOUT_MS = 30_000;
6565

66+
const MAX_RETRY_AFTER_MS = 60_000;
67+
68+
const clampRetryAfter = (ms: number): number =>
69+
Math.min(MAX_RETRY_AFTER_MS, Math.max(0, ms));
70+
6671
/**
6772
* `Retry-After` in seconds or as an HTTP date; undefined when absent.
6873
*
69-
* Both branches clamp at zero. A server may name an instant that has already
70-
* passed, and `Retry-After: -5` is not unheard of; a negative delay would flow
71-
* into the retry policy as though it were a pacing hint.
74+
* Both branches clamp to [0, 60s]. A server may name an instant that has
75+
* already passed, and `Retry-After: -5` is not unheard of; a negative delay
76+
* would flow into the retry policy as though it were a pacing hint. A day-long
77+
* `Retry-After` would stall the caller for that long.
7278
*
7379
* The HTTP-date branch is wall-clock by necessity — the header names an
7480
* absolute instant, so it cannot be resolved against the harness scheduler's
@@ -79,10 +85,10 @@ export const extractRetryAfterMs: RetryAfterExtractor = (headers) => {
7985
if (header === undefined || header === "") return undefined;
8086

8187
const seconds = Number(header);
82-
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
88+
if (Number.isFinite(seconds)) return clampRetryAfter(seconds * 1_000);
8389

8490
const at = Date.parse(header);
85-
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
91+
return Number.isNaN(at) ? undefined : clampRetryAfter(at - Date.now());
8692
};
8793

8894
async function attemptOnce(

0 commit comments

Comments
 (0)