| author | |
| committer | |
| log | eadeaf228267be215a2e9486f16b876d703dcccc |
| tree | 2700f9b658dd6e54fbbca9e24e72437c212e0335 |
| parent | bd5dc58c65b87bfd2222e4697c4742b48e639ea4 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 142 insertions(+), 10 deletions(-)
apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts+80-1| ... | @@ -1,11 +1,90 @@ | ... | @@ -1,11 +1,90 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 2 | 5 | ||
| 3 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 7 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | ||
| 8 | import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; | ||
| 4 | import { | 9 | import { |
| 5 | bitbucketProviderFailure, | 10 | bitbucketProviderFailure, |
| 6 | bitbucketViewerPermissions, | 11 | bitbucketViewerPermissions, |
| 12 | make, | ||
| 7 | } from "./BitbucketPullRequestProvider.ts"; | 13 | } from "./BitbucketPullRequestProvider.ts"; |
| 8 | 14 | ||
| 15 | for (const operation of [ | ||
| 16 | "getMergeability", | ||
| 17 | "listChecks", | ||
| 18 | "getRepositoryPermission", | ||
| 19 | "listComments", | ||
| 20 | "listCommits", | ||
| 21 | ] as const) { | ||
| 22 | it.effect.each(["response", "body read"])( | ||
| 23 | `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, | ||
| 24 | (variant) => | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const pullRequest = Result.getOrThrow( | ||
| 27 | decodePullRequestJson(`{ | ||
| 28 | "id": 1, "title": "Check polling", "state": "OPEN", | ||
| 29 | "source": { "branch": { "name": "feature" } }, | ||
| 30 | "destination": { "branch": { "name": "main" } }, | ||
| 31 | "created_on": "2026-09-16T00:00:00Z", | ||
| 32 | "updated_on": "2026-09-16T00:00:00Z", | ||
| 33 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } | ||
| 34 | }`), | ||
| 35 | ); | ||
| 36 | for (const status of [429, 403]) { | ||
| 37 | const provider = yield* make.pipe( | ||
| 38 | Effect.provide( | ||
| 39 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ | ||
| 40 | getPullRequest: () => Effect.succeed(pullRequest), | ||
| 41 | getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), | ||
| 42 | getMergeability: () => Effect.succeed("unknown" as const), | ||
| 43 | listChecks: () => Effect.succeed([]), | ||
| 44 | getRepositoryPermission: () => Effect.succeed(true), | ||
| 45 | listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), | ||
| 46 | listCommits: () => Effect.succeed([]), | ||
| 47 | [operation]: () => | ||
| 48 | Effect.fail( | ||
| 49 | variant === "response" | ||
| 50 | ? new BitbucketApi.BitbucketResponseError({ | ||
| 51 | operation: "request", | ||
| 52 | status, | ||
| 53 | responseBodyLength: 0, | ||
| 54 | retryAt: 120_000, | ||
| 55 | }) | ||
| 56 | : new BitbucketApi.BitbucketResponseBodyReadError({ | ||
| 57 | operation: "request", | ||
| 58 | status, | ||
| 59 | cause: new Error("response stream failed"), | ||
| 60 | retryAt: 120_000, | ||
| 61 | }), | ||
| 62 | ), | ||
| 63 | }), | ||
| 64 | ), | ||
| 65 | ); | ||
| 66 | const reference = { | ||
| 67 | cwd: "/repo", | ||
| 68 | repository: "acme/web", | ||
| 69 | number: 1, | ||
| 70 | host: "bitbucket.org", | ||
| 71 | }; | ||
| 72 | const result = yield* operation === "listComments" || operation === "listCommits" | ||
| 73 | ? Effect.result(provider.getChangeRequestActivity(reference)) | ||
| 74 | : Effect.result(provider.getChangeRequest(reference)); | ||
| 75 | if (status === 429) { | ||
| 76 | expect(result).toMatchObject({ | ||
| 77 | _tag: "Failure", | ||
| 78 | failure: { reason: "rate-limited", retryAt: 120_000 }, | ||
| 79 | }); | ||
| 80 | } else { | ||
| 81 | expect(result._tag).toBe("Success"); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 9 | describe("bitbucketProviderFailure", () => { | 88 | describe("bitbucketProviderFailure", () => { |
| 10 | it("treats only an HTTP 401 as unusable credentials", () => { | 89 | it("treats only an HTTP 401 as unusable credentials", () => { |
| 11 | const responseError = (status: number) => | 90 | const responseError = (status: number) => |
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+34-8| ... | @@ -73,7 +73,10 @@ export function bitbucketProviderFailure( | ... | @@ -73,7 +73,10 @@ export function bitbucketProviderFailure( |
| 73 | if (error._tag === "BitbucketResponseError" && error.status === 401) { | 73 | if (error._tag === "BitbucketResponseError" && error.status === 401) { |
| 74 | return { reason: "unauthenticated" }; | 74 | return { reason: "unauthenticated" }; |
| 75 | } | 75 | } |
| 76 | if (error._tag === "BitbucketResponseError" && error.status === 429) { | 76 | if ( |
| 77 | (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && | ||
| 78 | error.status === 429 | ||
| 79 | ) { | ||
| 77 | return { | 80 | return { |
| 78 | reason: "rate-limited", | 81 | reason: "rate-limited", |
| 79 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | 82 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), |
| ... | @@ -122,6 +125,31 @@ export const make = Effect.gen(function* () { | ... | @@ -122,6 +125,31 @@ export const make = Effect.gen(function* () { |
| 122 | cause: error, | 125 | cause: error, |
| 123 | }); | 126 | }); |
| 124 | 127 | ||
| 128 | const recoverRead = <A>( | ||
| 129 | read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>, | ||
| 130 | fallback: A, | ||
| 131 | ) => { | ||
| 132 | const recover = () => Effect.succeed(fallback); | ||
| 133 | return Effect.catchTags(read, { | ||
| 134 | BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), | ||
| 135 | BitbucketUntrustedUrlError: recover, | ||
| 136 | BitbucketRepositoryLocatorError: recover, | ||
| 137 | BitbucketRequestError: recover, | ||
| 138 | BitbucketResponseBodyReadError: (error) => | ||
| 139 | error.status === 429 ? Effect.fail(error) : recover(), | ||
| 140 | BitbucketResponseDecodeError: recover, | ||
| 141 | BitbucketRepositoryVcsResolveError: recover, | ||
| 142 | BitbucketRepositoryRemotesListError: recover, | ||
| 143 | BitbucketRepositoryRemoteNotFoundError: recover, | ||
| 144 | BitbucketPullRequestBodyReadError: recover, | ||
| 145 | BitbucketCheckoutError: recover, | ||
| 146 | BitbucketPullRequestReadError: recover, | ||
| 147 | BitbucketViewerUnavailableError: recover, | ||
| 148 | BitbucketRepositoryUnsupportedError: recover, | ||
| 149 | BitbucketDiffCommitError: recover, | ||
| 150 | }); | ||
| 151 | }; | ||
| 152 | |||
| 125 | const provider: PullRequestProviderApi = { | 153 | const provider: PullRequestProviderApi = { |
| 126 | kind: "bitbucket", | 154 | kind: "bitbucket", |
| 127 | capabilities: CAPABILITIES, | 155 | capabilities: CAPABILITIES, |
| ... | @@ -156,12 +184,12 @@ export const make = Effect.gen(function* () { | ... | @@ -156,12 +184,12 @@ export const make = Effect.gen(function* () { |
| 156 | [ | 184 | [ |
| 157 | api.getPullRequest(target), | 185 | api.getPullRequest(target), |
| 158 | api.getDiffStat(target), | 186 | api.getDiffStat(target), |
| 159 | api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)), | 187 | recoverRead(api.getMergeability(target), "unknown" as const), |
| 160 | api.listChecks(target).pipe(Effect.orElseSucceed(() => [])), | 188 | recoverRead(api.listChecks(target), []), |
| 161 | // A permission that could not be read is an unknown one, which is granted: a hidden | 189 | // A permission that could not be read is an unknown one, which is granted: a hidden |
| 162 | // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses | 190 | // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses |
| 163 | // at least says why. | 191 | // at least says why. |
| 164 | api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)), | 192 | recoverRead(api.getRepositoryPermission(target), true), |
| 165 | ], | 193 | ], |
| 166 | { concurrency: 5 }, | 194 | { concurrency: 5 }, |
| 167 | ).pipe( | 195 | ).pipe( |
| ... | @@ -200,10 +228,8 @@ export const make = Effect.gen(function* () { | ... | @@ -200,10 +228,8 @@ export const make = Effect.gen(function* () { |
| 200 | // Reviews ride on the pull request itself, so this inexpensive core read is repeated | 228 | // Reviews ride on the pull request itself, so this inexpensive core read is repeated |
| 201 | // here rather than making the core response wait for the conversation endpoints. | 229 | // here rather than making the core response wait for the conversation endpoints. |
| 202 | api.getPullRequest(target), | 230 | api.getPullRequest(target), |
| 203 | api | 231 | recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), |
| 204 | .listComments(target) | 232 | recoverRead(api.listCommits(target), []), |
| 205 | .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), | ||
| 206 | api.listCommits(target).pipe(Effect.orElseSucceed(() => [])), | ||
| 207 | ], | 233 | ], |
| 208 | { concurrency: 3 }, | 234 | { concurrency: 3 }, |
| 209 | ).pipe( | 235 | ).pipe( |
apps/server/src/sourceControl/BitbucketApi.test.ts+24| ... | @@ -618,6 +618,30 @@ it.effect("preserves Bitbucket response body read failures as their immediate ca | ... | @@ -618,6 +618,30 @@ it.effect("preserves Bitbucket response body read failures as their immediate ca |
| 618 | }).pipe(Effect.provide(layer)); | 618 | }).pipe(Effect.provide(layer)); |
| 619 | }); | 619 | }); |
| 620 | 620 | ||
| 621 | it.effect("keeps the 429 retry time when the response body cannot be read", () => { | ||
| 622 | const { layer } = makeLayer({ | ||
| 623 | response: () => | ||
| 624 | new Response( | ||
| 625 | new ReadableStream<Uint8Array>({ | ||
| 626 | start: (controller) => controller.error(new Error("response stream failed")), | ||
| 627 | }), | ||
| 628 | { status: 429, headers: { "Retry-After": "120" } }, | ||
| 629 | ), | ||
| 630 | }); | ||
| 631 | |||
| 632 | return Effect.gen(function* () { | ||
| 633 | yield* TestClock.setTime(1_000); | ||
| 634 | const bitbucket = yield* BitbucketApi.BitbucketApi; | ||
| 635 | const error = yield* bitbucket | ||
| 636 | .request({ method: "GET", url: "/repositories/acme/web" }) | ||
| 637 | .pipe(Effect.flip); | ||
| 638 | |||
| 639 | assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); | ||
| 640 | assert.strictEqual(error.status, 429); | ||
| 641 | assert.strictEqual(error.retryAt, 121_000); | ||
| 642 | }).pipe(Effect.provide(layer)); | ||
| 643 | }); | ||
| 644 | |||
| 621 | it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { | 645 | it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { |
| 622 | const { git, layer } = makeLayer({ | 646 | const { git, layer } = makeLayer({ |
| 623 | response: () => | 647 | response: () => |
apps/server/src/sourceControl/BitbucketApi.ts+4-1| ... | @@ -117,6 +117,7 @@ export class BitbucketResponseBodyReadError extends Schema.TaggedError<Bitbucket | ... | @@ -117,6 +117,7 @@ export class BitbucketResponseBodyReadError extends Schema.TaggedError<Bitbucket |
| 117 | { | 117 | { |
| 118 | operation: BitbucketApiOperation, | 118 | operation: BitbucketApiOperation, |
| 119 | status: Schema.Int, | 119 | status: Schema.Int, |
| 120 | retryAt: Schema.optional(Schema.Number), | ||
| 120 | cause: Schema.Defect(), | 121 | cause: Schema.Defect(), |
| 121 | }, | 122 | }, |
| 122 | ) { | 123 | ) { |
| ... | @@ -584,6 +585,7 @@ function responseError( | ... | @@ -584,6 +585,7 @@ function responseError( |
| 584 | // only its length is reported anyway. | 585 | // only its length is reported anyway. |
| 585 | return Effect.gen(function* () { | 586 | return Effect.gen(function* () { |
| 586 | const now = yield* Clock.currentTimeMillis; | 587 | const now = yield* Clock.currentTimeMillis; |
| 588 | const retryAt = retryAtFromHeader(response.headers["retry-after"], now); | ||
| 587 | const collected = yield* collectUint8StreamText({ | 589 | const collected = yield* collectUint8StreamText({ |
| 588 | stream: response.stream, | 590 | stream: response.stream, |
| 589 | maxBytes: DEFAULT_MAX_RESPONSE_BYTES, | 591 | maxBytes: DEFAULT_MAX_RESPONSE_BYTES, |
| ... | @@ -593,6 +595,7 @@ function responseError( | ... | @@ -593,6 +595,7 @@ function responseError( |
| 593 | new BitbucketResponseBodyReadError({ | 595 | new BitbucketResponseBodyReadError({ |
| 594 | operation, | 596 | operation, |
| 595 | status: response.status, | 597 | status: response.status, |
| 598 | retryAt, | ||
| 596 | cause, | 599 | cause, |
| 597 | }), | 600 | }), |
| 598 | ), | 601 | ), |
| ... | @@ -601,7 +604,7 @@ function responseError( | ... | @@ -601,7 +604,7 @@ function responseError( |
| 601 | operation, | 604 | operation, |
| 602 | status: response.status, | 605 | status: response.status, |
| 603 | responseBodyLength: collected.text.length, | 606 | responseBodyLength: collected.text.length, |
| 604 | retryAt: retryAtFromHeader(response.headers["retry-after"], now), | 607 | retryAt, |
| 605 | }); | 608 | }); |
| 606 | }); | 609 | }); |
| 607 | } | 610 | } |