@@ -692,6 +692,78 @@ describe("RequestClient", () => {
|
692 | 692 | expect(metrics.invalidRequestCountByStatus).toEqual({ 403: 1 }); |
693 | 693 | }); |
694 | 694 | |
| 695 | +it("bounds oversized REST response bodies instead of buffering them unbounded", async () => { |
| 696 | +const encoder = new TextEncoder(); |
| 697 | +let pullCount = 0; |
| 698 | +let cancelCount = 0; |
| 699 | +const fetchSpy = vi.fn( |
| 700 | +async () => |
| 701 | +new Response( |
| 702 | +new ReadableStream<Uint8Array>({ |
| 703 | +pull(controller) { |
| 704 | +pullCount += 1; |
| 705 | +// Flood far past the cap so an unbounded reader would OOM. |
| 706 | +controller.enqueue(encoder.encode("x".repeat(4 * 1024 * 1024))); |
| 707 | +}, |
| 708 | +cancel() { |
| 709 | +cancelCount += 1; |
| 710 | +}, |
| 711 | +}), |
| 712 | +{ status: 200 }, |
| 713 | +), |
| 714 | +); |
| 715 | +const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false }); |
| 716 | + |
| 717 | +await expect(client.get("/channels/c1/messages")).rejects.toThrow( |
| 718 | +/Discord REST response body exceeds 8388608 bytes/, |
| 719 | +); |
| 720 | +// The reader was cancelled at the cap rather than draining the whole flood: |
| 721 | +// only a handful of 4 MiB chunks are pulled before the cap is hit. |
| 722 | +expect(cancelCount).toBe(1); |
| 723 | +expect(pullCount).toBeLessThanOrEqual(4); |
| 724 | +}); |
| 725 | + |
| 726 | +it("aborts stalled REST response bodies after the idle timeout", async () => { |
| 727 | +const encoder = new TextEncoder(); |
| 728 | +let cancelReason: unknown; |
| 729 | +const fetchSpy = vi.fn( |
| 730 | +async () => |
| 731 | +new Response( |
| 732 | +new ReadableStream<Uint8Array>({ |
| 733 | +start(controller) { |
| 734 | +// Emit a partial chunk, then stall forever so the idle timeout |
| 735 | +// (request timeout) must fire and cancel the stream. |
| 736 | +controller.enqueue(encoder.encode("partial payload")); |
| 737 | +}, |
| 738 | +cancel(reason) { |
| 739 | +cancelReason = reason; |
| 740 | +}, |
| 741 | +}), |
| 742 | +{ status: 200 }, |
| 743 | +), |
| 744 | +); |
| 745 | +const client = new RequestClient("test-token", { |
| 746 | +fetch: fetchSpy, |
| 747 | +queueRequests: false, |
| 748 | +timeout: 50, |
| 749 | +}); |
| 750 | + |
| 751 | +await expect(client.get("/channels/c1/messages")).rejects.toThrow( |
| 752 | +"Discord REST response stalled: no data received for 50ms", |
| 753 | +); |
| 754 | +expect(cancelReason).toBeInstanceOf(Error); |
| 755 | +expect((cancelReason as Error).message).toBe( |
| 756 | +"Discord REST response stalled: no data received for 50ms", |
| 757 | +); |
| 758 | +}); |
| 759 | + |
| 760 | +it("still parses normal-sized REST response payloads under the cap", async () => { |
| 761 | +const fetchSpy = vi.fn(async () => createJsonResponse({ id: "channel", name: "general" })); |
| 762 | +const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false }); |
| 763 | + |
| 764 | +await expect(client.get("/channels/c1")).resolves.toEqual({ id: "channel", name: "general" }); |
| 765 | +}); |
| 766 | + |
695 | 767 | it("serializes message multipart uploads with payload_json", () => { |
696 | 768 | const headers = new Headers(); |
697 | 769 | const body = serializeRequestBody( |
|