Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ function cancelDecisionBodyBestEffort(response: Response, reason: string): void

/**
* Reads the private Durable Object rate-limit request through a fixed 256-byte buffer.
* After `getReader()` succeeds, every terminal path releases the reader lock in `finally`; declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock.
* Reader-acquisition failure is normalized before storage authority; after `getReader()` succeeds, every terminal path releases the reader lock in `finally`. Declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock.
* The byte ceiling, fatal UTF-8/JSON admission, and fail-closed cancellation semantics remain authoritative.
*/
async function readBoundedRateLimitRequest(request: Request): Promise<RateLimitRequestReadResult> {
Expand All @@ -294,7 +294,12 @@ async function readBoundedRateLimitRequest(request: Request): Promise<RateLimitR
return { ok: false, status: 400, error: "malformed_json" };
}

const reader = request.body.getReader();
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = request.body.getReader();
} catch {
return { ok: false, status: 400, error: "malformed_json" };
}
const requestStorage = new Uint8Array(MAX_RATE_LIMIT_REQUEST_BYTES);
let totalBytes = 0;
try {
Expand Down Expand Up @@ -341,7 +346,7 @@ async function readBoundedRateLimitRequest(request: Request): Promise<RateLimitR

/**
* Reads the private Durable Object rate-limit decision through a fixed 4,096-byte buffer.
* After `getReader()` succeeds, every terminal path releases the reader lock in `finally`; declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock.
* Reader-acquisition failure is normalized to the stable unavailable contract; after `getReader()` succeeds, every terminal path releases the reader lock in `finally`. Declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock.
* The byte ceiling, fatal UTF-8/JSON admission, and fail-closed cancellation semantics remain authoritative.
*/
async function readBoundedRateLimitDecision(response: Response): Promise<unknown> {
Expand All @@ -366,7 +371,14 @@ async function readBoundedRateLimitDecision(response: Response): Promise<unknown
);
}

const reader = response.body.getReader();
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = response.body.getReader();
} catch {
throw new DistributedRateLimitUnavailable(
"rate-limit Durable Object decision body could not be read",
);
}
const decisionStorage = new Uint8Array(MAX_RATE_LIMIT_DECISION_BYTES);
let totalBytes = 0;
try {
Expand Down
77 changes: 77 additions & 0 deletions test/rate-limit-locked-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import {
checkDistributedRateLimit,
DistributedRateLimitUnavailable,
type DistributedRateLimitEnv,
NoemaRateLimiter,
} from "../src/rate-limit";

function stateWithoutStorageAuthority(transaction: ReturnType<typeof vi.fn>): DurableObjectState {
return {
storage: { transaction },
} as unknown as DurableObjectState;
}

function envReturning(response: Response): DistributedRateLimitEnv {
return {
NOEMA_RATE_LIMITER: {
idFromName(name: string) {
return { toString: () => name } as DurableObjectId;
},
get() {
return {
fetch: async () => response,
} as unknown as DurableObjectStub;
},
} as unknown as DurableObjectNamespace,
};
}

describe("distributed rate-limit locked body acquisition", () => {
it("rejects a locked internal request as malformed before storage authority", async () => {
const transaction = vi.fn(async () => {
throw new Error("storage must not be reached for a locked limiter request");
});
const limiter = new NoemaRateLimiter(stateWithoutStorageAuthority(transaction));
const request = new Request("https://noema-rate-limit.internal/check", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"limit":60}',
});
vi.spyOn(request.body!, "getReader").mockImplementation(() => {
throw new TypeError("simulated locked rate-limit request body");
});

const response = await limiter.fetch(request);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
ok: false,
error: "malformed_json",
});
expect(transaction).not.toHaveBeenCalled();
});

it("normalizes a locked decision body to the stable unavailable contract", async () => {
const response = {
status: 200,
headers: new Headers({ "content-type": "application/json" }),
body: {
getReader(): never {
throw new TypeError("simulated locked rate-limit decision body");
},
},
} as unknown as Response;
const request = new Request("https://noema.example/exchange", {
headers: { "cf-connecting-ip": "203.0.113.92" },
});

await expect(
checkDistributedRateLimit(request, envReturning(response)),
).rejects.toThrow(
new DistributedRateLimitUnavailable(
"rate-limit Durable Object decision body could not be read",
),
);
});
});
Loading