diff --git a/src/oauth/oauth-flow-service.ts b/src/oauth/oauth-flow-service.ts index bad3f29a..41ae2903 100644 --- a/src/oauth/oauth-flow-service.ts +++ b/src/oauth/oauth-flow-service.ts @@ -396,8 +396,14 @@ export class OAuthFlowService { }; } catch (error) { if (error instanceof ProviderDispatchRequestError) { - if (request) await this.requests.fail(request.connectionRequestId, "rate_limited", error.message); - throw error; + if (!request) throw error; + await this.requests.fail(request.connectionRequestId, "rate_limited", error.message); + throw new OAuthCallbackError( + "rate_limited", + error.message, + callbackReturnUri(request, "error", "rate_limited", error.message), + error, + ); } if (request) { const code = diff --git a/src/server/connect-server.ts b/src/server/connect-server.ts index 4e46c473..bafedb07 100644 --- a/src/server/connect-server.ts +++ b/src/server/connect-server.ts @@ -420,12 +420,16 @@ export class ConnectServer { else app.notFound(notFound); app.onError((error, context) => { if (error instanceof ProviderDispatchRequestError) { - return writeRuntimeFailure(context, { - status: 429, - errorCode: "rate_limited", - message: error.message, - data: toProviderExecutionError(error, error.message).error?.details, - }); + if (context.req.path.startsWith("/v1/")) + return writeRuntimeFailure(context, { + status: 429, + errorCode: "rate_limited", + message: error.message, + data: toProviderExecutionError(error, error.message).error?.details, + }); + const seconds = optionalRecord(error.details)?.retryAfterSeconds; + if (typeof seconds === "number") context.header("Retry-After", String(seconds)); + return jsonError(context, 429, "rate_limited", error.message); } if (error instanceof SaasError) { this.options.logger?.warn( diff --git a/src/server/connector-runtime.test.ts b/src/server/connector-runtime.test.ts index 1f5844b2..0f0c2133 100644 --- a/src/server/connector-runtime.test.ts +++ b/src/server/connector-runtime.test.ts @@ -21,6 +21,126 @@ afterEach(async () => { }); describe("headless runtime", () => { + it.each([45, undefined])( + "preserves console and runtime denial formats with retry delay %s", + async (retryAfterSeconds) => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + runtime = await createConnectorRuntime({ + ...(await fixture()), + providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds }) }, + }); + const consoleResponse = await request( + "/api/connections/github", + { authType: "api_key", values: { apiKey: "fixture-secret" } }, + "admin-token", + "PUT", + ); + const runtimeResponse = await request("/v1/connections/github/connect/api-key", { apiKey: "fixture-secret" }); + for (const response of [consoleResponse, runtimeResponse]) { + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe( + retryAfterSeconds === undefined ? null : String(retryAfterSeconds), + ); + } + expect(await consoleResponse.json()).toEqual({ + error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." }, + }); + expect(await runtimeResponse.json()).toMatchObject({ + success: false, + errorCode: "rate_limited", + message: "Provider HTTP dispatch is temporarily unavailable.", + }); + expect(fetcher).not.toHaveBeenCalled(); + expect((await (await request("/v1/connections")).json()).data).toEqual([]); + }, + ); + + it.each([ + ["oauth", "https://host.example/settings/connections"], + ["credential_validation", "https://host.example/settings/connections"], + ["oauth", undefined], + ["credential_validation", undefined], + ])("completes a denied %s connection request with return URI %s", async (operation, returnUri) => { + const fetcher = vi.fn(async () => + Response.json({ access_token: "fixture-access-token", token_type: "bearer", scope: "read:user" }), + ); + vi.stubGlobal("fetch", fetcher); + runtime = await createConnectorRuntime({ + ...(await fixture()), + providerHttpDispatch: { + beforeAttempt: (attempt) => + attempt.context.operation === operation ? { allow: false, retryAfterSeconds: 45 } : { allow: true }, + }, + }); + expect( + ( + await request( + "/api/oauth/configs/github", + { clientId: "fixture-client", clientSecret: "fixture-secret" }, + "admin-token", + "PUT", + ) + ).status, + ).toBe(200); + const started = await request("/v1/connections/github/connect", { returnUri }); + expect(started.status).toBe(200); + const attempt = (await started.json()).data; + const state = new URL(attempt.authorizationUrl).searchParams.get("state")!; + const path = `/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`; + const callback = await request(path); + if (returnUri) { + expect(callback.status).toBe(302); + const returned = new URL(callback.headers.get("location")!); + expect(`${returned.origin}${returned.pathname}`).toBe(returnUri); + expect(Object.fromEntries(returned.searchParams)).toEqual({ + status: "error", + service: "github", + code: "rate_limited", + message: "Provider HTTP dispatch is temporarily unavailable.", + }); + } else { + expect(callback.status).toBe(400); + expect(await callback.json()).toEqual({ + error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." }, + }); + } + expect(callback.headers.get("Retry-After")).toBeNull(); + const completed = await request(`/v1/connection-requests/${attempt.connectionRequestId}`); + expect((await completed.json()).data).toMatchObject({ status: "failed", errorCode: "rate_limited" }); + expect((await (await request("/v1/connections")).json()).data).toEqual([]); + expect(fetcher).toHaveBeenCalledTimes(operation === "oauth" ? 0 : 1); + const replay = await request(path); + expect(replay.status).toBe(400); + expect(await replay.json()).toMatchObject({ error: { code: "invalid_oauth_state" } }); + }); + + it("preserves dispatch denial for OAuth authorizations without a connection request", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + runtime = await createConnectorRuntime({ + ...(await fixture()), + providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds: 45 }) }, + }); + await request( + "/api/oauth/configs/github", + { clientId: "fixture-client", clientSecret: "fixture-secret" }, + "admin-token", + "PUT", + ); + const started = await request("/api/oauth/authorizations", { service: "github" }); + expect(started.status).toBe(200); + const { state } = await started.json(); + const callback = await request(`/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`); + expect(callback.status).toBe(429); + expect(callback.headers.get("Retry-After")).toBe("45"); + expect(await callback.json()).toEqual({ + error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." }, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect((await (await request("/v1/connections")).json()).data).toEqual([]); + }); + it("preserves admission denial after a real provider remaps it into validation and transport errors", async () => { let deny = false; const fetcher = vi.fn(async () => Response.json({ credit_balance: 100 }));