cursedbelt-server 4.40.4 → 4.41.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.
|
@@ -38,6 +38,8 @@ export interface BinaryStoreConfig {
|
|
|
38
38
|
absenceTtlMs?: number;
|
|
39
39
|
/** Injected so the memo's expiry is testable without a real clock. */
|
|
40
40
|
now?: () => number;
|
|
41
|
+
/** Injected so a `paced` read's waits are testable without real time. Default: a timer. */
|
|
42
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
41
43
|
/**
|
|
42
44
|
* 🔴 How THIS PROCESS sends every request to binary-server. Default: the global `fetch`.
|
|
43
45
|
*
|
|
@@ -154,7 +156,25 @@ export interface ReadOptions {
|
|
|
154
156
|
* hour" is answered by reloading the picture.
|
|
155
157
|
*/
|
|
156
158
|
fresh?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* 🔴 Wait out binary-server's read ceiling instead of throwing it (task 2505).
|
|
161
|
+
*
|
|
162
|
+
* The store allows a tenant a burst of reads, then a steady rate, and answers 429 with the
|
|
163
|
+
* `retry-after` it wants honoured (`apps/binary-server/src/rateLimit.ts`). A batch read —
|
|
164
|
+
* roms filing a disc's 256 parts, a hash backfill, an import — met it at read ~204 and threw
|
|
165
|
+
* the whole pass away. `paced` makes `get`/`meta`/`stat` sleep the named `retry-after` and ask
|
|
166
|
+
* again, for at most {@link BINARY_PACED_MAX_WAIT_MS} in total (or `{ maxWaitMs }`).
|
|
167
|
+
*
|
|
168
|
+
* Opt-in, because a page read a person is waiting on should fail fast rather than hang:
|
|
169
|
+
* without it a 429 throws {@link BinaryStoreRateLimitedError}, which carries the
|
|
170
|
+
* `retryAfterSec` so a caller pacing itself need not guess.
|
|
171
|
+
*/
|
|
172
|
+
paced?: boolean | {
|
|
173
|
+
maxWaitMs?: number;
|
|
174
|
+
};
|
|
157
175
|
}
|
|
176
|
+
/** How long a `paced` read waits out 429s in total before it throws — see {@link ReadOptions.paced}. */
|
|
177
|
+
export declare const BINARY_PACED_MAX_WAIT_MS = 120000;
|
|
158
178
|
/** What the memos currently hold — for an owner surface, a smoke, and the tests. */
|
|
159
179
|
export interface BinaryCacheStats {
|
|
160
180
|
/** Keys known to be present. Never expires; see {@link BINARY_ABSENCE_TTL_MS}. */
|
|
@@ -436,3 +456,19 @@ export declare class BinaryStoreUnreachableError extends Error {
|
|
|
436
456
|
* required vars are all present — callers fall back to their local backend.
|
|
437
457
|
*/
|
|
438
458
|
export declare function readBinaryStoreEnv(appId: string, env?: Record<string, string | undefined>): BinaryStoreConfig | null;
|
|
459
|
+
/**
|
|
460
|
+
* binary-server refused a READ for its rate ceiling (429) — a pace, not a verdict, and not an
|
|
461
|
+
* absence (nothing is memoized). Thrown by `get`/`meta`/`stat` without `paced`, or with `paced`
|
|
462
|
+
* once its wait budget is spent. The message keeps the `[binary-store] <op> <key>: 429` shape
|
|
463
|
+
* earlier callers matched on; `retryAfterSec` is what the store asked for.
|
|
464
|
+
*/
|
|
465
|
+
export declare class BinaryStoreRateLimitedError extends Error {
|
|
466
|
+
readonly retryAfterSec: number;
|
|
467
|
+
/** How long a `paced` read had already waited before giving up — 0 when it was not paced. */
|
|
468
|
+
readonly waitedMs: number;
|
|
469
|
+
readonly status = 429;
|
|
470
|
+
readonly code = "BYTE_STORE_RATE_LIMITED";
|
|
471
|
+
constructor(op: string, key: string, retryAfterSec: number,
|
|
472
|
+
/** How long a `paced` read had already waited before giving up — 0 when it was not paced. */
|
|
473
|
+
waitedMs?: number);
|
|
474
|
+
}
|
|
@@ -129,6 +129,8 @@ export function wantsFresh(req) {
|
|
|
129
129
|
* that only just crosses the line. Being early is free; being late is a 413.
|
|
130
130
|
*/
|
|
131
131
|
export const AUTO_CHUNK_THRESHOLD_BYTES = 64 * 1024 * 1024;
|
|
132
|
+
/** How long a `paced` read waits out 429s in total before it throws — see {@link ReadOptions.paced}. */
|
|
133
|
+
export const BINARY_PACED_MAX_WAIT_MS = 120_000;
|
|
132
134
|
/**
|
|
133
135
|
* 🔴 How long "the store does not hold this key" is believed — the NEGATIVE memo.
|
|
134
136
|
*
|
|
@@ -263,6 +265,7 @@ export function createBinaryStore(cfg) {
|
|
|
263
265
|
const send = cfg.fetch ?? ((input, init) => fetch(input, init));
|
|
264
266
|
const autoChunkBytes = cfg.autoChunkBytes ?? AUTO_CHUNK_THRESHOLD_BYTES;
|
|
265
267
|
const now = cfg.now ?? Date.now;
|
|
268
|
+
const sleep = cfg.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
266
269
|
const absenceTtlMs = cfg.absenceTtlMs ?? BINARY_ABSENCE_TTL_MS;
|
|
267
270
|
/*
|
|
268
271
|
* ── 🔴 The shared picture cache (2026-09-09) ───────────────────────────────
|
|
@@ -409,6 +412,28 @@ export function createBinaryStore(cfg) {
|
|
|
409
412
|
}
|
|
410
413
|
return { 'x-internal-secret': cfg.internalSecret };
|
|
411
414
|
};
|
|
415
|
+
/**
|
|
416
|
+
* One read, with a 429 handled the one way every read handles it: waited out when the
|
|
417
|
+
* caller asked for `paced` and the budget allows, otherwise thrown as a typed error that
|
|
418
|
+
* keeps the `[binary-store] <op> <key>: 429` message batch callers already match.
|
|
419
|
+
*/
|
|
420
|
+
const sendRead = async (op, key, url, init, opts) => {
|
|
421
|
+
const budget = opts?.paced
|
|
422
|
+
? (typeof opts.paced === 'object' ? opts.paced.maxWaitMs : undefined) ?? BINARY_PACED_MAX_WAIT_MS
|
|
423
|
+
: 0;
|
|
424
|
+
let waited = 0;
|
|
425
|
+
for (let attempt = 0;; attempt++) {
|
|
426
|
+
const res = await send(url, init);
|
|
427
|
+
if (res.status !== 429)
|
|
428
|
+
return res;
|
|
429
|
+
const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
|
|
430
|
+
const ms = retryAfterSec * 1000;
|
|
431
|
+
if (waited + ms > budget)
|
|
432
|
+
throw new BinaryStoreRateLimitedError(op, key, retryAfterSec, waited);
|
|
433
|
+
await sleep(ms);
|
|
434
|
+
waited += ms;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
412
437
|
const store = {
|
|
413
438
|
appId: cfg.appId,
|
|
414
439
|
signToken,
|
|
@@ -523,7 +548,7 @@ export function createBinaryStore(cfg) {
|
|
|
523
548
|
if (!opts?.fresh && knownAbsent(key))
|
|
524
549
|
return null;
|
|
525
550
|
countWireRead(key);
|
|
526
|
-
const res = await
|
|
551
|
+
const res = await sendRead('get', key, await internalMediaUrl(key), undefined, opts);
|
|
527
552
|
if (res.status === 404) {
|
|
528
553
|
rememberAbsent(key);
|
|
529
554
|
return null;
|
|
@@ -599,7 +624,7 @@ export function createBinaryStore(cfg) {
|
|
|
599
624
|
return null;
|
|
600
625
|
}
|
|
601
626
|
countWireRead(key);
|
|
602
|
-
const res = await
|
|
627
|
+
const res = await sendRead('meta', key, await metaUrl(key), undefined, opts);
|
|
603
628
|
if (res.status === 404) {
|
|
604
629
|
rememberAbsent(key);
|
|
605
630
|
return null;
|
|
@@ -639,11 +664,14 @@ export function createBinaryStore(cfg) {
|
|
|
639
664
|
if (m)
|
|
640
665
|
return { size: m.size };
|
|
641
666
|
}
|
|
642
|
-
catch {
|
|
643
|
-
// A transport/5xx failure — the probe below is the second opinion.
|
|
667
|
+
catch (why) {
|
|
668
|
+
// A transport/5xx failure — the probe below is the second opinion. A 429 is not: the
|
|
669
|
+
// store answered "slow down", and a second read straight away only spends more of it.
|
|
670
|
+
if (why instanceof BinaryStoreRateLimitedError)
|
|
671
|
+
throw why;
|
|
644
672
|
}
|
|
645
673
|
countWireRead(key);
|
|
646
|
-
const res = await
|
|
674
|
+
const res = await sendRead('stat', key, await internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
|
|
647
675
|
if (res.status === 404) {
|
|
648
676
|
rememberAbsent(key);
|
|
649
677
|
return null;
|
|
@@ -817,3 +845,34 @@ export function readBinaryStoreEnv(appId, env = process.env) {
|
|
|
817
845
|
internalBaseUrl: env.BINARY_SERVER_INTERNAL_URL?.trim() || undefined,
|
|
818
846
|
};
|
|
819
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* The seconds a 429 asks for. binary-server always names `retry-after` (1-30 s); a
|
|
850
|
+
* missing or junk one — an edge in front of it, an older server — backs off 1, 2, 4… s
|
|
851
|
+
* rather than hammering, capped at the same 30 s the server caps itself at.
|
|
852
|
+
*/
|
|
853
|
+
function retryAfterSeconds(header, attempt) {
|
|
854
|
+
const named = Number(header);
|
|
855
|
+
if (header !== null && Number.isFinite(named) && named >= 0)
|
|
856
|
+
return Math.min(30, Math.max(1, Math.ceil(named)));
|
|
857
|
+
return Math.min(30, 2 ** Math.min(attempt, 5));
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* binary-server refused a READ for its rate ceiling (429) — a pace, not a verdict, and not an
|
|
861
|
+
* absence (nothing is memoized). Thrown by `get`/`meta`/`stat` without `paced`, or with `paced`
|
|
862
|
+
* once its wait budget is spent. The message keeps the `[binary-store] <op> <key>: 429` shape
|
|
863
|
+
* earlier callers matched on; `retryAfterSec` is what the store asked for.
|
|
864
|
+
*/
|
|
865
|
+
export class BinaryStoreRateLimitedError extends Error {
|
|
866
|
+
retryAfterSec;
|
|
867
|
+
waitedMs;
|
|
868
|
+
status = 429;
|
|
869
|
+
code = 'BYTE_STORE_RATE_LIMITED';
|
|
870
|
+
constructor(op, key, retryAfterSec,
|
|
871
|
+
/** How long a `paced` read had already waited before giving up — 0 when it was not paced. */
|
|
872
|
+
waitedMs = 0) {
|
|
873
|
+
super(`[binary-store] ${op} ${key}: 429`);
|
|
874
|
+
this.retryAfterSec = retryAfterSec;
|
|
875
|
+
this.waitedMs = waitedMs;
|
|
876
|
+
this.name = 'BinaryStoreRateLimitedError';
|
|
877
|
+
}
|
|
878
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.41.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -45,8 +45,13 @@ import {
|
|
|
45
45
|
storeUnreachableStatus,
|
|
46
46
|
wantsFresh,
|
|
47
47
|
} from './binaryStore.js';
|
|
48
|
+
import * as binaryStoreModule from './binaryStore.js';
|
|
48
49
|
import { binaryStoreFakeDefaults } from './binaryStoreFake.js';
|
|
49
50
|
|
|
51
|
+
// Read off the namespace, not named imports, so the task-2505 specs run (and go red on the
|
|
52
|
+
// BEHAVIOUR) against a build that predates these exports — which is what prove-red does.
|
|
53
|
+
const { BINARY_PACED_MAX_WAIT_MS, BinaryStoreRateLimitedError } = binaryStoreModule;
|
|
54
|
+
|
|
50
55
|
// One 2048-bit keypair for the whole file — generation is the slow part, and every
|
|
51
56
|
// assertion below only needs *a* valid PKCS8 PEM and its public half.
|
|
52
57
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
@@ -1019,3 +1024,80 @@ describe('🔴 a write the store could not ANSWER is a 503 that names it (2026-0
|
|
|
1019
1024
|
expect(String((error as Error).message)).toBe('[binary-store] put k: 403');
|
|
1020
1025
|
});
|
|
1021
1026
|
});
|
|
1027
|
+
|
|
1028
|
+
describe('🔴 a read the store PACES is waited out when asked, typed when not (task 2505)', () => {
|
|
1029
|
+
// roms filed a 1 GiB disc by reading 256 parts back to back; binary-server's tenant ceiling
|
|
1030
|
+
// answered 429 at part ~204 and the plain throw dropped the whole pass. Each app was about to
|
|
1031
|
+
// hand-roll its own `/: 429$/` regex and guess the wait the store had already named.
|
|
1032
|
+
const limited = (retryAfter: string | null = '2') =>
|
|
1033
|
+
new Response('{"error":"rate_limited"}', {
|
|
1034
|
+
status: 429,
|
|
1035
|
+
headers: retryAfter === null ? {} : { 'retry-after': retryAfter },
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
/** Answers 429 `refusals` times, then the bytes; records every sleep. */
|
|
1039
|
+
const pacedStore = (refusals: number, retryAfter: string | null = '2') => {
|
|
1040
|
+
const slept: number[] = [];
|
|
1041
|
+
let n = 0;
|
|
1042
|
+
reply = () => (n++ < refusals ? limited(retryAfter) : ok(new Uint8Array([7, 8])));
|
|
1043
|
+
const store = makeStore({
|
|
1044
|
+
sleep: (ms) => {
|
|
1045
|
+
slept.push(ms);
|
|
1046
|
+
return Promise.resolve();
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
return { store, slept };
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
it('paced get sleeps the retry-after the store named and returns the bytes', async () => {
|
|
1053
|
+
const { store, slept } = pacedStore(3);
|
|
1054
|
+
expect(await store.get('parts/0', { paced: true })).toEqual(new Uint8Array([7, 8]));
|
|
1055
|
+
expect(slept).toEqual([2000, 2000, 2000]);
|
|
1056
|
+
expect(calls).toHaveLength(4);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it('without paced, a 429 is the typed error carrying retryAfterSec — and the old message', async () => {
|
|
1060
|
+
const { store, slept } = pacedStore(1, '7');
|
|
1061
|
+
const error = await store.get('parts/0').catch((e: unknown) => e);
|
|
1062
|
+
expect(error).toBeInstanceOf(BinaryStoreRateLimitedError);
|
|
1063
|
+
expect((error as BinaryStoreRateLimitedError).retryAfterSec).toBe(7);
|
|
1064
|
+
expect((error as Error).message).toBe('[binary-store] get parts/0: 429');
|
|
1065
|
+
expect(slept).toEqual([]);
|
|
1066
|
+
expect(store.known('parts/0'), 'a pace is not an absence').toBe('unknown');
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('a paced read gives up once its wait budget is spent, saying how long it waited', async () => {
|
|
1070
|
+
const { store, slept } = pacedStore(Number.POSITIVE_INFINITY, '30');
|
|
1071
|
+
const error = await store.get('parts/0', { paced: { maxWaitMs: 65_000 } }).catch((e: unknown) => e);
|
|
1072
|
+
expect(error).toBeInstanceOf(BinaryStoreRateLimitedError);
|
|
1073
|
+
expect(slept).toEqual([30_000, 30_000]);
|
|
1074
|
+
expect((error as BinaryStoreRateLimitedError).waitedMs).toBe(60_000);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('the default budget is bounded', async () => {
|
|
1078
|
+
const { store, slept } = pacedStore(Number.POSITIVE_INFINITY, '30');
|
|
1079
|
+
await expect(store.get('parts/0', { paced: true })).rejects.toBeInstanceOf(BinaryStoreRateLimitedError);
|
|
1080
|
+
expect(slept.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(BINARY_PACED_MAX_WAIT_MS);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('no retry-after backs off 1, 2, 4 s rather than hammering', async () => {
|
|
1084
|
+
const { store, slept } = pacedStore(3, null);
|
|
1085
|
+
await store.get('parts/0', { paced: true });
|
|
1086
|
+
expect(slept).toEqual([1000, 2000, 4000]);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('meta waits it out too, and stat does not spend a second read probing after a 429', async () => {
|
|
1090
|
+
const paced = pacedStore(1);
|
|
1091
|
+
reply = (() => {
|
|
1092
|
+
let n = 0;
|
|
1093
|
+
return () => (n++ < 1 ? limited() : json({ size: 5, mime: 'x/y' }));
|
|
1094
|
+
})();
|
|
1095
|
+
expect((await paced.store.meta('m', { paced: true }))?.size).toBe(5);
|
|
1096
|
+
expect(paced.slept).toEqual([2000]);
|
|
1097
|
+
|
|
1098
|
+
calls = [];
|
|
1099
|
+
reply = () => limited();
|
|
1100
|
+
await expect(makeStore().stat('s')).rejects.toBeInstanceOf(BinaryStoreRateLimitedError);
|
|
1101
|
+
expect(calls, 'the Range probe must not follow a 429').toHaveLength(1);
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
@@ -83,6 +83,8 @@ export interface BinaryStoreConfig {
|
|
|
83
83
|
absenceTtlMs?: number;
|
|
84
84
|
/** Injected so the memo's expiry is testable without a real clock. */
|
|
85
85
|
now?: () => number;
|
|
86
|
+
/** Injected so a `paced` read's waits are testable without real time. Default: a timer. */
|
|
87
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
86
88
|
/**
|
|
87
89
|
* 🔴 How THIS PROCESS sends every request to binary-server. Default: the global `fetch`.
|
|
88
90
|
*
|
|
@@ -254,8 +256,25 @@ export interface ReadOptions {
|
|
|
254
256
|
* hour" is answered by reloading the picture.
|
|
255
257
|
*/
|
|
256
258
|
fresh?: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* 🔴 Wait out binary-server's read ceiling instead of throwing it (task 2505).
|
|
261
|
+
*
|
|
262
|
+
* The store allows a tenant a burst of reads, then a steady rate, and answers 429 with the
|
|
263
|
+
* `retry-after` it wants honoured (`apps/binary-server/src/rateLimit.ts`). A batch read —
|
|
264
|
+
* roms filing a disc's 256 parts, a hash backfill, an import — met it at read ~204 and threw
|
|
265
|
+
* the whole pass away. `paced` makes `get`/`meta`/`stat` sleep the named `retry-after` and ask
|
|
266
|
+
* again, for at most {@link BINARY_PACED_MAX_WAIT_MS} in total (or `{ maxWaitMs }`).
|
|
267
|
+
*
|
|
268
|
+
* Opt-in, because a page read a person is waiting on should fail fast rather than hang:
|
|
269
|
+
* without it a 429 throws {@link BinaryStoreRateLimitedError}, which carries the
|
|
270
|
+
* `retryAfterSec` so a caller pacing itself need not guess.
|
|
271
|
+
*/
|
|
272
|
+
paced?: boolean | { maxWaitMs?: number };
|
|
257
273
|
}
|
|
258
274
|
|
|
275
|
+
/** How long a `paced` read waits out 429s in total before it throws — see {@link ReadOptions.paced}. */
|
|
276
|
+
export const BINARY_PACED_MAX_WAIT_MS = 120_000;
|
|
277
|
+
|
|
259
278
|
/** What the memos currently hold — for an owner surface, a smoke, and the tests. */
|
|
260
279
|
export interface BinaryCacheStats {
|
|
261
280
|
/** Keys known to be present. Never expires; see {@link BINARY_ABSENCE_TTL_MS}. */
|
|
@@ -526,6 +545,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
526
545
|
const send = cfg.fetch ?? ((input: string | Request, init?: RequestInit) => fetch(input, init));
|
|
527
546
|
const autoChunkBytes = cfg.autoChunkBytes ?? AUTO_CHUNK_THRESHOLD_BYTES;
|
|
528
547
|
const now = cfg.now ?? Date.now;
|
|
548
|
+
const sleep = cfg.sleep ?? ((ms: number) => new Promise((done) => setTimeout(done, ms)));
|
|
529
549
|
const absenceTtlMs = cfg.absenceTtlMs ?? BINARY_ABSENCE_TTL_MS;
|
|
530
550
|
|
|
531
551
|
/*
|
|
@@ -700,6 +720,33 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
700
720
|
return { 'x-internal-secret': cfg.internalSecret };
|
|
701
721
|
};
|
|
702
722
|
|
|
723
|
+
/**
|
|
724
|
+
* One read, with a 429 handled the one way every read handles it: waited out when the
|
|
725
|
+
* caller asked for `paced` and the budget allows, otherwise thrown as a typed error that
|
|
726
|
+
* keeps the `[binary-store] <op> <key>: 429` message batch callers already match.
|
|
727
|
+
*/
|
|
728
|
+
const sendRead = async (
|
|
729
|
+
op: string,
|
|
730
|
+
key: string,
|
|
731
|
+
url: string,
|
|
732
|
+
init: RequestInit | undefined,
|
|
733
|
+
opts: ReadOptions | undefined,
|
|
734
|
+
): Promise<Response> => {
|
|
735
|
+
const budget = opts?.paced
|
|
736
|
+
? (typeof opts.paced === 'object' ? opts.paced.maxWaitMs : undefined) ?? BINARY_PACED_MAX_WAIT_MS
|
|
737
|
+
: 0;
|
|
738
|
+
let waited = 0;
|
|
739
|
+
for (let attempt = 0; ; attempt++) {
|
|
740
|
+
const res = await send(url, init);
|
|
741
|
+
if (res.status !== 429) return res;
|
|
742
|
+
const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
|
|
743
|
+
const ms = retryAfterSec * 1000;
|
|
744
|
+
if (waited + ms > budget) throw new BinaryStoreRateLimitedError(op, key, retryAfterSec, waited);
|
|
745
|
+
await sleep(ms);
|
|
746
|
+
waited += ms;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
703
750
|
const store: BinaryStore = {
|
|
704
751
|
appId: cfg.appId,
|
|
705
752
|
signToken,
|
|
@@ -828,7 +875,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
828
875
|
// short-circuit here — we want the bytes, and only the store has them.
|
|
829
876
|
if (!opts?.fresh && knownAbsent(key)) return null;
|
|
830
877
|
countWireRead(key);
|
|
831
|
-
const res = await
|
|
878
|
+
const res = await sendRead('get', key, await internalMediaUrl(key), undefined, opts);
|
|
832
879
|
if (res.status === 404) {
|
|
833
880
|
rememberAbsent(key);
|
|
834
881
|
return null;
|
|
@@ -905,7 +952,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
905
952
|
if (knownAbsent(key)) return null;
|
|
906
953
|
}
|
|
907
954
|
countWireRead(key);
|
|
908
|
-
const res = await
|
|
955
|
+
const res = await sendRead('meta', key, await metaUrl(key), undefined, opts);
|
|
909
956
|
if (res.status === 404) {
|
|
910
957
|
rememberAbsent(key);
|
|
911
958
|
return null;
|
|
@@ -942,11 +989,13 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
942
989
|
try {
|
|
943
990
|
const m = await store.meta(key, opts);
|
|
944
991
|
if (m) return { size: m.size };
|
|
945
|
-
} catch {
|
|
946
|
-
// A transport/5xx failure — the probe below is the second opinion.
|
|
992
|
+
} catch (why) {
|
|
993
|
+
// A transport/5xx failure — the probe below is the second opinion. A 429 is not: the
|
|
994
|
+
// store answered "slow down", and a second read straight away only spends more of it.
|
|
995
|
+
if (why instanceof BinaryStoreRateLimitedError) throw why;
|
|
947
996
|
}
|
|
948
997
|
countWireRead(key);
|
|
949
|
-
const res = await
|
|
998
|
+
const res = await sendRead('stat', key, await internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
|
|
950
999
|
if (res.status === 404) {
|
|
951
1000
|
rememberAbsent(key);
|
|
952
1001
|
return null;
|
|
@@ -1145,3 +1194,35 @@ export function readBinaryStoreEnv(
|
|
|
1145
1194
|
internalBaseUrl: env.BINARY_SERVER_INTERNAL_URL?.trim() || undefined,
|
|
1146
1195
|
};
|
|
1147
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* The seconds a 429 asks for. binary-server always names `retry-after` (1-30 s); a
|
|
1200
|
+
* missing or junk one — an edge in front of it, an older server — backs off 1, 2, 4… s
|
|
1201
|
+
* rather than hammering, capped at the same 30 s the server caps itself at.
|
|
1202
|
+
*/
|
|
1203
|
+
function retryAfterSeconds(header: string | null, attempt: number): number {
|
|
1204
|
+
const named = Number(header);
|
|
1205
|
+
if (header !== null && Number.isFinite(named) && named >= 0) return Math.min(30, Math.max(1, Math.ceil(named)));
|
|
1206
|
+
return Math.min(30, 2 ** Math.min(attempt, 5));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* binary-server refused a READ for its rate ceiling (429) — a pace, not a verdict, and not an
|
|
1211
|
+
* absence (nothing is memoized). Thrown by `get`/`meta`/`stat` without `paced`, or with `paced`
|
|
1212
|
+
* once its wait budget is spent. The message keeps the `[binary-store] <op> <key>: 429` shape
|
|
1213
|
+
* earlier callers matched on; `retryAfterSec` is what the store asked for.
|
|
1214
|
+
*/
|
|
1215
|
+
export class BinaryStoreRateLimitedError extends Error {
|
|
1216
|
+
readonly status = 429;
|
|
1217
|
+
readonly code = 'BYTE_STORE_RATE_LIMITED';
|
|
1218
|
+
constructor(
|
|
1219
|
+
op: string,
|
|
1220
|
+
key: string,
|
|
1221
|
+
readonly retryAfterSec: number,
|
|
1222
|
+
/** How long a `paced` read had already waited before giving up — 0 when it was not paced. */
|
|
1223
|
+
readonly waitedMs = 0,
|
|
1224
|
+
) {
|
|
1225
|
+
super(`[binary-store] ${op} ${key}: 429`);
|
|
1226
|
+
this.name = 'BinaryStoreRateLimitedError';
|
|
1227
|
+
}
|
|
1228
|
+
}
|