Skip to content

Commit 97670c5

Browse files
authored
fix(dispatch): harden static asset storage failures (#243)
* fix(dispatch): harden static asset storage failures * fix(dispatch): address asset fallback review * fix(dispatch): preserve pre-stub timeout attribution
1 parent 168e5fe commit 97670c5

13 files changed

Lines changed: 2090 additions & 125 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ The edge router.
250250

251251
- Reads slug/domain routing from KV.
252252
- Sends worker projects to Workers for Platforms.
253-
- Serves static/SPA deployments directly from `STATIC_ASSETS` KV with R2 overflow for large files.
253+
- Serves current static/SPA deployments directly from R2, while retaining KV reads for small assets carried forward from legacy deploys.
254254
- Handles ISR, edge caching, redirect/rewrite rules, header rules, and request metering.
255255
- Relays Cloudflare Queue batches to user workers.
256256

‎packages/api/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Dashboard auth uses the same JWT model, stored in the `void_token` cookie.
152152
### Static/SPA deploys
153153

154154
1. content-address assets by hash
155-
2. store small assets in `STATIC_ASSETS` KV and large assets in `STATIC_ASSETS_R2`
155+
2. issue presigned uploads that store every new asset directly in `STATIC_ASSETS_R2` (`STATIC_ASSETS` KV is retained for carry-forward assets from legacy multipart deploys)
156156
3. write a manifest to `ROUTING`
157157
4. point routing at `staticDeploymentId` instead of a worker
158158

‎packages/api/src/usage-counter.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export type UsageEvent = Readonly<{
2727
requests: number;
2828
responseBytes: number;
2929
status?: number;
30+
failureKind?:
31+
| 'platform-static-asset-timeout'
32+
| 'platform-static-asset-error'
33+
| 'platform-static-asset-missing';
3034
sandboxRuntimeSeconds?: number;
3135
sandboxInstanceId?: string;
3236
}>;

‎packages/api/src/usage-queue.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type { ApiEnv } from './env.ts';
33
import { getPlanLimits } from './tiers.ts';
44
import type { UsageEvent } from './usage-counter.ts';
55

6+
const PLATFORM_STATIC_ASSET_FAILURES: ReadonlySet<NonNullable<UsageEvent['failureKind']>> = new Set(
7+
['platform-static-asset-timeout', 'platform-static-asset-error', 'platform-static-asset-missing'],
8+
);
9+
610
export function groupEventsByUser(
711
events: ReadonlyArray<UsageEvent>,
812
): Map<string, ReadonlyArray<UsageEvent>> {
@@ -72,9 +76,15 @@ export async function processUsageEventBatch(
7276
if (failures.length > 0) {
7377
batch.retryAll();
7478
} else {
75-
// Best-effort: route 5xx events to ErrorAlertMonitor (failures don't block ack)
79+
// Best-effort: route project 5xx events to ErrorAlertMonitor (failures
80+
// don't block ack). Platform-owned static storage failures remain billed
81+
// and visible in request analytics, but do not tell users that their
82+
// application returned an error.
7683
const errorRecords = records.filter(
77-
({ event }) => event.status !== undefined && event.status >= 500,
84+
({ event }) =>
85+
event.status !== undefined &&
86+
event.status >= 500 &&
87+
!(event.failureKind !== undefined && PLATFORM_STATIC_ASSET_FAILURES.has(event.failureKind)),
7888
);
7989
if (errorRecords.length > 0) {
8090
const errorsByProject = new Map<

‎packages/api/test/unit/usage-queue.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,50 @@ describe('processUsageEventBatch — 5xx alert window', () => {
192192
expect(p1).toMatchObject({ firstAt: early, lastAt: early });
193193
expect(p2).toMatchObject({ firstAt: late, lastAt: late });
194194
});
195+
196+
it('bills platform static-asset failures without sending user error alerts', async () => {
197+
const stubFetch = vi.fn(async () => new Response(null, { status: 200 }));
198+
const errorStubFetch = vi.fn(async () => new Response(null, { status: 200 }));
199+
getUserMock.mockResolvedValue({ id: 'u1', plan: 'solo' });
200+
201+
const platformFailures: Array<UsageEvent> = [
202+
{ ...error5xx('docs'), failureKind: 'platform-static-asset-timeout' },
203+
{ ...error5xx('docs'), failureKind: 'platform-static-asset-error' },
204+
{ ...error5xx('docs'), failureKind: 'platform-static-asset-missing' },
205+
];
206+
const batch = makeBatch(platformFailures);
207+
208+
await processUsageEventBatch(batch as never, makeEnv(stubFetch, errorStubFetch) as never);
209+
210+
expect(stubFetch).toHaveBeenCalledOnce();
211+
const counterBody = (await fetchCalls(stubFetch)[0][0].json()) as {
212+
events: Array<UsageEvent>;
213+
};
214+
expect(counterBody.events).toEqual(platformFailures);
215+
expect(errorStubFetch).not.toHaveBeenCalled();
216+
expect(batch.calls.ackAll).toBe(1);
217+
});
218+
219+
it('still alerts for application 5xx events mixed with a platform timeout', async () => {
220+
const stubFetch = vi.fn(async () => new Response(null, { status: 200 }));
221+
const errorStubFetch = vi.fn(async () => new Response(null, { status: 200 }));
222+
getUserMock.mockResolvedValue({ id: 'u1', plan: 'solo' });
223+
224+
const platformTimeout: UsageEvent = {
225+
...error5xx('docs'),
226+
failureKind: 'platform-static-asset-timeout',
227+
};
228+
const appFailure = error5xx('repl');
229+
const batch = makeBatch([platformTimeout, appFailure]);
230+
231+
await processUsageEventBatch(batch as never, makeEnv(stubFetch, errorStubFetch) as never);
232+
233+
expect(errorStubFetch).toHaveBeenCalledOnce();
234+
const alertBody = (await fetchCalls(errorStubFetch)[0][0].json()) as {
235+
projectId: string;
236+
count: number;
237+
};
238+
expect(alertBody).toMatchObject({ projectId: 'repl', count: 1 });
239+
expect(batch.calls.ackAll).toBe(1);
240+
});
195241
});

‎packages/dispatch/README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ Edge router for deployed Void apps. It routes requests to user workers, serves s
99
3. Reject suspended projects with `429`.
1010
4. Apply `redirectRules` (pre-asset) before any worker invocation. Redirects short-circuit with a `Location` header; status-200 entries are "force" rewrites that mutate `url.pathname` and continue.
1111
5. Serve hashed assets from edge cache when possible.
12-
6. For static/SPA projects, serve files from `STATIC_ASSETS` KV with `STATIC_ASSETS_R2` overflow for large assets, using `assetConfig` path resolution.
12+
6. For static/SPA projects, serve current direct-upload assets from `STATIC_ASSETS_R2`; preserve `STATIC_ASSETS` KV reads for assets carried forward from legacy deploys. Storage reads get one delayed hedge within the existing 10-second request budget, then use `assetConfig` path resolution.
1313
7. Apply `fallbackRules` (post-asset) only when static asset resolution misses — instead of returning 404, match the pathname against fallback rules (status=200 only) and rewrite. Intended for SPA shells (`/* → /index.html`) and default-locale catch-alls. On static deployments, a matched fallback serves the resolved asset directly without dispatching to a user worker, so `X-Void-Original-URL` is never set, `c.originalUrl()` is unavailable, and the response is not ISR-cached (see `packages/dispatch/test/unit/fallback-rules.test.ts`).
1414
8. For `/api/*`, forward to the user worker in the dispatch namespace with plan-specific CPU/subrequest limits.
1515
9. For SSR with ISR enabled, use `caches.default` plus `ISR_CACHE` KV for stale-while-revalidate behavior.
16-
10. For WfP deploys, inspect the worker response for the Cloudflare asset-dedupe-poisoning stub (`Assets have not yet been deployed to this script.`). On detection, if the routing entry has `assetManifestId`, re-serve from the platform-owned mirror (`STATIC_ASSETS`/`STATIC_ASSETS_R2`). See `meta/design-docs/0066` and `meta/design-docs/0065`.
17-
11. Apply header rules and record usage in Analytics Engine. If `userId` is present on the routing entry, enqueue usage events as well.
16+
10. For WfP deploys, inspect worker and cache responses for the Cloudflare asset-dedupe-poisoning stub (`Assets have not yet been deployed to this script.`). On detection, purge any poisoned cache entry and re-serve from the platform-owned mirror (`STATIC_ASSETS`/`STATIC_ASSETS_R2`). If the mirror is unavailable or incomplete, return an attributed `503` instead of passing through and caching the known-bad `200` stub. See `meta/design-docs/0066` and `meta/design-docs/0065`.
17+
11. Apply header rules and record usage in Analytics Engine. If `userId` is present on the routing entry, enqueue usage events as well. Platform-owned static storage failures carry a `failureKind`: they remain metered and queryable but are excluded from customer application-error emails.
1818

1919
Two rule lists exist so dispatch never has to reason about per-rule phase flags at the edge:
2020

@@ -23,8 +23,8 @@ Two rule lists exist so dispatch never has to reason about per-rule phase flags
2323

2424
## Static and ISR Storage
2525

26-
- **Static/SPA deploys**: manifest in `ROUTING`, assets in `STATIC_ASSETS` KV, overflow in `STATIC_ASSETS_R2`
27-
- **WfP deploys with asset mirror**: same stores as static (manifest at `manifest:{deploymentId}`, assets at `assets/{projectId}/{hash}`). `RoutingEntry.assetManifestId` points at the manifest. CF's `ASSETS` binding remains primary — this mirror is only consulted when the CF stub is detected.
26+
- **Static/SPA deploys**: manifest in `ROUTING`; current direct-upload assets in `STATIC_ASSETS_R2`. `STATIC_ASSETS` KV remains readable for small assets carried forward from legacy multipart deploys.
27+
- **WfP deploys with asset mirror**: same project-scoped storage and manifest format as static (manifest at `manifest:{deploymentId}`, assets at `assets/{projectId}/{hash}`). `RoutingEntry.assetManifestId` points at the manifest. CF's `ASSETS` binding remains primary — this mirror is only consulted when the CF stub is detected.
2828
- **ISR cache**: packed HTML/headers stored in `ISR_CACHE` KV, with edge-cache copies in `caches.default`. Keys are (project, deployment, pathname, optional rewrite-variant token) so a direct hit to `/en/docs` and a request rewritten from `/docs → /en/docs` live in distinct slots — user code can branch on `c.isRewritten()` / `c.originalUrl()` and the cache honors that. The variant suffix is derived from `X-Void-Original-URL` via the shared helpers in [`packages/isr-shared`](../isr-shared/README.md); direct hits (no rewrite, no header) use the unsuffixed key.
2929
- **ISR TTL resolution**: a request's TTL resolves route-aware — dispatch first-matches the path against the manifest's ordered `pages` table (`{ pattern, revalidate }`, in the framework's route order, **not** re-sorted) so a specific page shadows a broad catch-all, then falls back to the flat `revalidate` pattern→TTL map for deploys that predate `pages`. Only a positive-TTL, non-catch-all match is ISR-eligible; API routes and asset-extension paths that merely match a catch-all render live (`resolvePageTtl` / `isIsrEligiblePath` in `isr.ts`).
3030
- **Hashed assets**: immutable edge-cache entries keyed by URL path

0 commit comments

Comments
 (0)