-
Notifications
You must be signed in to change notification settings - Fork 145
Answer Dr. CI auth failures with a status instead of an unhandled 500 #8840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| /** | ||
| * The Dr. CI endpoint's authorization block, which runs before the handler's | ||
| * own try/catch. | ||
| * | ||
| * Every fault here must still produce a response. One that escapes leaves the | ||
| * function with none and the platform answers 500, and trymerge reads any | ||
| * failure of this call as "no classifications" -- so an escaping fault | ||
| * degrades merges quietly instead of reporting itself. | ||
| */ | ||
|
|
||
| import { NextApiRequest } from "next"; | ||
| import * as githubModule from "../lib/github"; | ||
| import * as rateLimitModule from "../lib/rateLimit"; | ||
| import handler from "../pages/api/drci/drci"; | ||
| import { mockRes } from "./nextApiMocks"; | ||
|
|
||
| jest.mock("../lib/github", () => { | ||
| const actual = jest.requireActual("../lib/github"); | ||
| return { | ||
| ...actual, | ||
| getOctokitWithUserToken: jest.fn(), | ||
| getOctokit: jest.fn(), | ||
| }; | ||
| }); | ||
|
|
||
| jest.mock("../lib/rateLimit", () => ({ | ||
| drCIRateLimitExceeded: jest.fn(), | ||
| incrementDrCIRateLimit: jest.fn(), | ||
| })); | ||
|
|
||
| const mockGetOctokitWithUserToken = | ||
| githubModule.getOctokitWithUserToken as jest.Mock; | ||
| const mockGetOctokit = githubModule.getOctokit as jest.Mock; | ||
| const mockRateLimitExceeded = | ||
| rateLimitModule.drCIRateLimitExceeded as jest.Mock; | ||
| const mockIncrementRateLimit = | ||
| rateLimitModule.incrementDrCIRateLimit as jest.Mock; | ||
|
|
||
| /** Rejecting getOctokit is the cheapest proof the handler body was reached. */ | ||
| const REACHED_BODY = "reached the handler body"; | ||
|
|
||
| function mockReq( | ||
| authorization: string | undefined, | ||
| query: Record<string, string> = {} | ||
| ): NextApiRequest { | ||
| return { | ||
| method: "POST", | ||
| headers: authorization === undefined ? {} : { authorization }, | ||
| query, | ||
| body: { repo: "pytorch", org: "pytorch" }, | ||
| } as unknown as NextApiRequest; | ||
| } | ||
|
|
||
| function octokitThatRejects(error: Error) { | ||
| return { | ||
| rest: { | ||
| users: { getAuthenticated: jest.fn().mockRejectedValue(error) }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function octokitForLogin(login: string) { | ||
| return { | ||
| rest: { | ||
| users: { | ||
| getAuthenticated: jest.fn().mockResolvedValue({ data: { login } }), | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** What Octokit raises when GitHub refuses the token. */ | ||
| function badCredentials() { | ||
| return Object.assign(new Error("Bad credentials"), { status: 401 }); | ||
| } | ||
|
|
||
| describe("Dr. CI authorization", () => { | ||
| const savedBotKey = process.env.DRCI_BOT_KEY; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| process.env.DRCI_BOT_KEY = "the-bot-key"; | ||
| mockRateLimitExceeded.mockResolvedValue(false); | ||
| mockIncrementRateLimit.mockResolvedValue(undefined); | ||
| mockGetOctokit.mockRejectedValue(new Error(REACHED_BODY)); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| if (savedBotKey === undefined) { | ||
| delete process.env.DRCI_BOT_KEY; | ||
| } else { | ||
| process.env.DRCI_BOT_KEY = savedBotKey; | ||
| } | ||
| }); | ||
|
|
||
| test("a credential GitHub refuses answers 401, not an unhandled throw", async () => { | ||
| mockGetOctokitWithUserToken.mockResolvedValue( | ||
| octokitThatRejects(badCredentials()) | ||
| ); | ||
| const res = mockRes(); | ||
|
|
||
| await expect( | ||
| handler( | ||
| mockReq("a-key-that-no-longer-matches", { prNumber: "1000" }), | ||
| res | ||
| ) | ||
| ).resolves.not.toThrow(); | ||
|
|
||
| expect(res._status).toBe(401); | ||
| expect(res._json.error).toBe("Invalid credentials"); | ||
| expect(res._headers["WWW-Authenticate"]).toBe("Bearer"); | ||
| }); | ||
|
|
||
| test("a GitHub fault that is not a refusal answers 503, not 401", async () => { | ||
| // Reaching GitHub at all is this endpoint's problem, not the caller's, so | ||
| // it must not be reported as bad credentials. | ||
| mockGetOctokitWithUserToken.mockResolvedValue( | ||
| octokitThatRejects(new Error("getaddrinfo ENOTFOUND api.github.com")) | ||
| ); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(res._status).toBe(503); | ||
| expect(res._json.error).toBe("Authentication service unavailable"); | ||
| }); | ||
|
|
||
| test("a rate-limit read failure answers 503 and leaks no backend detail", async () => { | ||
| mockGetOctokitWithUserToken.mockResolvedValue(octokitForLogin("someone")); | ||
| mockRateLimitExceeded.mockRejectedValue( | ||
| new Error("clickhouse://secret-host:8123 refused the connection") | ||
| ); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(res._status).toBe(503); | ||
| expect(res._json.error).toBe("Authentication service unavailable"); | ||
| expect(JSON.stringify(res._json)).not.toContain("secret-host"); | ||
| }); | ||
|
|
||
| test("a rate-limit WRITE failure refuses rather than serving unmetered", async () => { | ||
| // The increment used to be neither awaited nor caught, so its rejection | ||
| // escaped as an unhandled one. It is the limit itself, not bookkeeping: | ||
| // serving on a failed insert lets any authenticated user drive unbounded | ||
| // work under the service's bot credentials without consuming quota. | ||
| mockGetOctokitWithUserToken.mockResolvedValue(octokitForLogin("someone")); | ||
| mockIncrementRateLimit.mockRejectedValue(new Error("insert failed")); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(mockIncrementRateLimit).toHaveBeenCalledWith("someone"); | ||
| expect(res._status).toBe(503); | ||
| expect(mockGetOctokit).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("a factory failure is handled like any other resolution failure", async () => { | ||
| mockGetOctokitWithUserToken.mockRejectedValue( | ||
| new Error("cannot construct") | ||
| ); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(res._status).toBe(503); | ||
| expect(res._json.error).toBe("Authentication service unavailable"); | ||
| }); | ||
|
|
||
| test("an upstream 403 is reported as unavailable, not as bad credentials", async () => { | ||
| // GitHub answers 403 for secondary rate limiting and missing scope, which | ||
| // are not "your token is wrong" -- reporting them as 401 is the | ||
| // misdirection this split exists to avoid. | ||
| mockGetOctokitWithUserToken.mockResolvedValue( | ||
| octokitThatRejects( | ||
| Object.assign(new Error("API rate limit exceeded"), { status: 403 }) | ||
| ) | ||
| ); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(res._status).toBe(503); | ||
| }); | ||
|
|
||
| test("an authenticated user under the limit reaches the handler body", async () => { | ||
| mockGetOctokitWithUserToken.mockResolvedValue(octokitForLogin("someone")); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(mockIncrementRateLimit).toHaveBeenCalledWith("someone"); | ||
| expect(res._status).toBe(400); | ||
| expect(res._json.error).toContain(REACHED_BODY); | ||
| }); | ||
|
|
||
| test("an over-limit user still gets 429", async () => { | ||
| mockGetOctokitWithUserToken.mockResolvedValue(octokitForLogin("someone")); | ||
| mockRateLimitExceeded.mockResolvedValue(true); | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token", { prNumber: "1000" }), res); | ||
|
|
||
| expect(res._status).toBe(429); | ||
| // Asserted because 429 answers with .end(): a response that sets a status | ||
| // and never ends leaves the request hanging, which a status-only | ||
| // assertion cannot see. | ||
| expect(res._ended).toBe(true); | ||
| expect(mockIncrementRateLimit).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("a user token without prNumber still gets 403", async () => { | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("some-user-token"), res); | ||
|
|
||
| expect(res._status).toBe(403); | ||
| expect(res._ended).toBe(true); | ||
| expect(mockGetOctokitWithUserToken).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("no Authorization header gets 403", async () => { | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq(undefined), res); | ||
|
|
||
| expect(res._status).toBe(403); | ||
| expect(res._ended).toBe(true); | ||
| }); | ||
|
|
||
| test("an unset DRCI_BOT_KEY does not admit an anonymous caller as the bot", async () => { | ||
| // undefined == undefined is true, so the bare comparison used to treat | ||
| // every anonymous request as the bot whenever the deployment lost the var. | ||
| delete process.env.DRCI_BOT_KEY; | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq(undefined), res); | ||
|
|
||
| expect(res._status).toBe(403); | ||
| }); | ||
|
|
||
| test("an empty DRCI_BOT_KEY does not admit an empty Authorization header", async () => { | ||
| process.env.DRCI_BOT_KEY = ""; | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq(""), res); | ||
|
|
||
| expect(res._status).toBe(403); | ||
| expect(mockGetOctokitWithUserToken).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("the bot key skips the user path and reaches the handler body", async () => { | ||
| const res = mockRes(); | ||
|
|
||
| await handler(mockReq("the-bot-key"), res); | ||
|
|
||
| expect(mockGetOctokitWithUserToken).not.toHaveBeenCalled(); | ||
| expect(mockRateLimitExceeded).not.toHaveBeenCalled(); | ||
| expect(res._status).toBe(400); | ||
| expect(res._json.error).toContain(REACHED_BODY); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the kind of thing that gets me puzzled, should we update this with the same change, given it is the same repo?
🟡 greenlight's drci_poke comment still states that a Dr. CI auth failure answers 500 because the auth branch sits outside the try/catch (ai-generated section)
This change wraps the Dr. CI authorization branch in its own try/catch and answers 401 or 503 instead of falling through to the platform's 500. Two comments elsewhere in this repository assert the opposite and are not updated:
greenlight/src/greenlight/drci_poke.py:127-128reads "An auth failure answers 500, not 403 -- the endpoint's auth branch sits outside its try/catch -- so the status code cannot classify the failure. Log it and move on.", andgreenlight/tests/test_drci_poke.py:180repeats "An auth failure answers 500, so a non-2xx cannot be classified -- but it must never raise." Both clauses are now false: the branch is inside a try/catch (this line), and a GitHub refusal answers 401 (drci.ts:160) while a dependency fault answers 503 (drci.ts:164).greenlight/src/greenlight/drci_poke.py:107POSTs toDRCI_ENDPOINT=https://hud.pytorch.org/api/drci/drci(greenlight/src/greenlight/constants.py:66), i.e. exactly the route this diff changes, and logs the non-2xx status at drci_poke.py:129 under the comment quoted above. The diff addstry {at drci.ts:133 around the whole auth block and addsres.status(401)at drci.ts:160 andres.status(503)at drci.ts:164, so the status code now does classify the failure for precisely the case the comment says it cannot. A maintainer debugging a failed poke reads drci_poke.py:127, concludes the logged status is uninformative, and ignores a 401 or 503 that now names the cause -- the exact misdiagnosis this change exists to prevent. The stale sentence also blocks the obvious follow-up of classifying the status inpoke, since the comment documents it as impossible. Replace drci_poke.py:127-128 with something like# The endpoint distinguishes its failures: 401 means the token was refused, 503 means Dr. CI could not reach GitHub or ClickHouse. The poke still swallows all of them -- log the code and move on.and update the matching sentence atgreenlight/tests/test_drci_poke.py:180the same way.Reviewed by claude-opus-5[1m] at max effort, against 6cab1a9.