diff --git a/src/api-wrappers/bluesky.test.ts b/src/api-wrappers/bluesky.test.ts index 4e71ba1..4903441 100644 --- a/src/api-wrappers/bluesky.test.ts +++ b/src/api-wrappers/bluesky.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { BlueSky } from "./bluesky"; import { AtpAgent, ComAtprotoServerCreateSession } from "@atproto/api"; import { mockEnv } from "../../tests/fixtures/env"; +import { RateLimitError } from "../errors/rate-limit-error"; type CreateSessionResponse = ComAtprotoServerCreateSession.Response; @@ -103,6 +104,42 @@ describe("BlueSky", () => { expect(bluesky).toBeInstanceOf(BlueSky); }); + + it("should handle rate limiting during login", async () => { + vi.mocked(mockEnv.BLUESKY_SESSION_STORAGE.get).mockResolvedValueOnce( + null, + ); + + const mockRateLimitError = { + error: "RateLimitExceeded", + headers: { + "ratelimit-limit": "100", + "ratelimit-policy": "100;w=86400", + "ratelimit-remaining": "0", + "ratelimit-reset": "1733077560", + }, + }; + + vi.mocked(agent.login).mockRejectedValueOnce(mockRateLimitError); + + const promise = BlueSky.retrieveAgent(mockEnv); + + await expect(promise).rejects.toThrow(RateLimitError); + await expect(promise).rejects.toThrow("Rate limited until 1733077560"); + }); + + it("should pass through other errors during login", async () => { + vi.mocked(mockEnv.BLUESKY_SESSION_STORAGE.get).mockResolvedValueOnce( + null, + ); + + const originalError = new Error("Some other error"); + vi.mocked(agent.login).mockRejectedValueOnce(originalError); + + const promise = BlueSky.retrieveAgent(mockEnv); + + await expect(promise).rejects.toThrow(originalError); + }); }); describe("postMessage", () => { diff --git a/src/api-wrappers/bluesky.ts b/src/api-wrappers/bluesky.ts index 9c1d121..9baaa5a 100644 --- a/src/api-wrappers/bluesky.ts +++ b/src/api-wrappers/bluesky.ts @@ -6,6 +6,8 @@ import { } from "@atproto/api"; import { Env } from "../types"; +import { RateLimitError } from "../errors/rate-limit-error"; +import { BlueskyRateLimitExceededError } from "../types/bluesky"; const SESSION_KEY = "session"; @@ -28,20 +30,30 @@ export class BlueSky { } static async retrieveAgent(env: Env): Promise { - const bluesky = new BlueSky(env); + try { + const bluesky = new BlueSky(env); - const existingSessionData = - await env.BLUESKY_SESSION_STORAGE.get(SESSION_KEY); + const existingSessionData = + await env.BLUESKY_SESSION_STORAGE.get(SESSION_KEY); - if (existingSessionData) { - const sessionData = JSON.parse(existingSessionData); + if (existingSessionData) { + const sessionData = JSON.parse(existingSessionData); - await bluesky.agent.resumeSession(sessionData); - } else { - await bluesky.login(env.BSKY_USERNAME, env.BSKY_PASSWORD); - } + await bluesky.agent.resumeSession(sessionData); + } else { + await bluesky.login(env.BSKY_USERNAME, env.BSKY_PASSWORD); + } - return bluesky; + return bluesky; + } catch (error: unknown) { + if (isRateLimitError(error)) { + throw new RateLimitError( + `Rate limited until ${error.headers["ratelimit-reset"]}`, + ); + } + + throw error; + } } async getProfile(): Promise< @@ -94,3 +106,15 @@ export class BlueSky { }); } } + +const isRateLimitError = ( + error: unknown, +): error is BlueskyRateLimitExceededError => { + return ( + typeof error === "object" && + error !== null && + "error" in error && + error.error === "RateLimitExceeded" && + "headers" in error + ); +}; diff --git a/src/errors/rate-limit-error.ts b/src/errors/rate-limit-error.ts new file mode 100644 index 0000000..16010e5 --- /dev/null +++ b/src/errors/rate-limit-error.ts @@ -0,0 +1,6 @@ +export class RateLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "RateLimitError"; + } +} diff --git a/src/types/bluesky.ts b/src/types/bluesky.ts new file mode 100644 index 0000000..6900318 --- /dev/null +++ b/src/types/bluesky.ts @@ -0,0 +1,10 @@ +export interface BlueskyRateLimitExceededError { + name: "RateLimitExceeded"; + headers: { + "ratelimit-limit": string; + "ratelimit-policy": string; + "ratelimit-remaining": string; + "ratelimit-reset": string; + }; + statusText: string; +}