authorgravatar for 7121943+kvnloo@users.noreply.github.comKevin Rajan <7121943+kvnloo@users.noreply.github.com> 2026-10-14 07:15:13-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 05:15:13-07:00
logdf8df0d53b39bfd6a5d8d69fabade19bd668e0a5
treefa87f33c072e9d07a17bd95fa22897d066ed8e5e
parent7d5a42912a4036549ac4c70d0195f02af432115a
signature Signed by PGP key B5690EEEBB952194

fix(server): bound Bitbucket API requests with 30s timeout (#14140)


2 files changed, 96 insertions(+), 3 deletions(-)

apps/server/src/sourceControl/BitbucketApi.test.ts+84-3
......@@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
44import * as ConfigProvider from "effect/ConfigProvider";
55import * as DateTime from "effect/DateTime";
66import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
78import * as FileSystem from "effect/FileSystem";
89import * as Layer from "effect/Layer";
910import * as Option from "effect/Option";
......@@ -65,13 +66,16 @@ function makeLayer(input: {
6566 readonly requestFailure?: (
6667 request: HttpClientRequest.HttpClientRequest,
6768 ) => HttpClientError.HttpClientError;
69 readonly stall?: boolean;
6870 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
6971 readonly env?: Record<string, string>;
7072}) {
7173 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
72 input.requestFailure
73 ? Effect.fail(input.requestFailure(request))
74 : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
74 input.stall
75 ? Effect.never
76 : input.requestFailure
77 ? Effect.fail(input.requestFailure(request))
78 : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
7579 );
7680 const gitMock = {
7781 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"]>(() =>
......@@ -1099,3 +1103,80 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo
10991103 ),
11001104 ),
11011105);
1106
1107const stalledResponseBody = (status: number): Response =>
1108 new Response(
1109 new ReadableStream<Uint8Array>({
1110 start(controller) {
1111 controller.enqueue(new TextEncoder().encode("{"));
1112 // Deliberately never close: headers arrive before the body stalls.
1113 },
1114 }),
1115 { status, headers: { "content-type": "application/json" } },
1116 );
1117
1118it.effect.each([200, 503])(
1119 "times out a stalled Bitbucket JSON body after HTTP %s headers",
1120 (status) => {
1121 const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) });
1122 return Effect.gen(function* () {
1123 const api = yield* BitbucketApi.BitbucketApi;
1124 const fiber = yield* Effect.forkChild(
1125 Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })),
1126 );
1127 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1128 yield* TestClock.adjust("31 seconds");
1129 const error = yield* Fiber.join(fiber);
1130 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1131 assert.strictEqual(error.operation, "getPullRequest");
1132 }).pipe(Effect.provide(layer));
1133 },
1134);
1135
1136it.effect.each([200, 503])(
1137 "times out a stalled Bitbucket raw body after HTTP %s headers",
1138 (status) => {
1139 const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) });
1140 return Effect.gen(function* () {
1141 const api = yield* BitbucketApi.BitbucketApi;
1142 const fiber = yield* Effect.forkChild(
1143 Effect.flip(api.request({ method: "GET", url: "/user" })),
1144 );
1145 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1146 yield* TestClock.adjust("31 seconds");
1147 const error = yield* Fiber.join(fiber);
1148 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1149 assert.strictEqual(error.operation, "request");
1150 }).pipe(Effect.provide(layer));
1151 },
1152);
1153
1154it.effect("times out before JSON headers arrive", () => {
1155 const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true });
1156 return Effect.gen(function* () {
1157 const api = yield* BitbucketApi.BitbucketApi;
1158 const fiber = yield* Effect.forkChild(
1159 Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })),
1160 );
1161 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1162 yield* TestClock.adjust("31 seconds");
1163 const error = yield* Fiber.join(fiber);
1164 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1165 assert.strictEqual(error.operation, "getPullRequest");
1166 }).pipe(Effect.provide(layer));
1167});
1168
1169it.effect("times out before raw response headers arrive", () => {
1170 const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true });
1171 return Effect.gen(function* () {
1172 const api = yield* BitbucketApi.BitbucketApi;
1173 const fiber = yield* Effect.forkChild(
1174 Effect.flip(api.request({ method: "GET", url: "/user" })),
1175 );
1176 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1177 yield* TestClock.adjust("31 seconds");
1178 const error = yield* Fiber.join(fiber);
1179 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1180 assert.strictEqual(error.operation, "request");
1181 }).pipe(Effect.provide(layer));
1182});
packages/source-control-bitbucket/src/server/BitbucketApi.ts+12
......@@ -39,6 +39,8 @@ const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
3939const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
4040/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */
4141const MAX_REDIRECTS = 3;
42/** A single API operation must bound both header delivery and body consumption. */
43const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds";
4244
4345const BitbucketApiEnvConfig = Config.all({
4446 baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
......@@ -732,6 +734,10 @@ export const make = Effect.gen(function* () {
732734 }),
733735 ),
734736 Effect.flatMap((response) => decodeResponse(operation, schema, response)),
737 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
738 Effect.catchTags({
739 TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })),
740 }),
735741 );
736742
737743 const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
......@@ -968,6 +974,12 @@ export const make = Effect.gen(function* () {
968974 orElse: (failed) => responseError("request", failed),
969975 })(response),
970976 ),
977 // This also caps redirect hops: no request can finish headers then stall its body forever.
978 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
979 Effect.catchTags({
980 TimeoutError: (cause) =>
981 Effect.fail(new BitbucketRequestError({ operation: "request", cause })),
982 }),
971983 );
972984
973985 return BitbucketApi.of({