diff --git a/src/account_x/__tests__/unit/controller.core.unit.test.ts b/src/account_x/__tests__/unit/controller.core.unit.test.ts index 65f10369..2c75059e 100644 --- a/src/account_x/__tests__/unit/controller.core.unit.test.ts +++ b/src/account_x/__tests__/unit/controller.core.unit.test.ts @@ -183,3 +183,59 @@ test("test migration: 20241127_add_deletedAt_fields", async () => { // Cleanup migrationContext.cleanup(); }); + +const deleteURL = + "https://x.com/i/api/graphql/nxpZCY2K-I6QoFHAHeojFQ/DeleteTweet"; + +test("observeDeleteRateLimitHeaders() marks the account rate limited when a delete mutation has little quota left", async () => { + controller.observeDeleteRateLimitHeaders({ + url: deleteURL, + statusCode: 200, + responseHeaders: { + "x-rate-limit-limit": ["50"], + "x-rate-limit-remaining": ["2"], + "x-rate-limit-reset": ["1700000000"], + }, + }); + + expect(await controller.isRateLimited()).toEqual({ + isRateLimited: true, + rateLimitReset: 1700000000, + }); +}); + +test("observeDeleteRateLimitHeaders() leaves the account alone while quota remains", async () => { + controller.observeDeleteRateLimitHeaders({ + url: deleteURL, + statusCode: 200, + responseHeaders: { + "x-rate-limit-remaining": ["3"], + "x-rate-limit-reset": ["1700000000"], + }, + }); + + expect((await controller.isRateLimited()).isRateLimited).toBe(false); +}); + +test("observeDeleteRateLimitHeaders() ignores endpoints other than the delete mutations", async () => { + controller.observeDeleteRateLimitHeaders({ + url: "https://x.com/i/api/graphql/abc123/CreatorStudioTabBarItemQuery", + statusCode: 200, + responseHeaders: { + "x-rate-limit-remaining": ["0"], + "x-rate-limit-reset": ["1700000000"], + }, + }); + + expect((await controller.isRateLimited()).isRateLimited).toBe(false); +}); + +test("observeDeleteRateLimitHeaders() ignores responses without rate limit headers", async () => { + controller.observeDeleteRateLimitHeaders({ + url: deleteURL, + statusCode: 200, + responseHeaders: { "content-type": ["application/json"] }, + }); + + expect((await controller.isRateLimited()).isRateLimited).toBe(false); +}); diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 609343d3..e1c3f581 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -21,6 +21,7 @@ import { XIndexTimelineStats, emptyXIndexTimelineStats, DEFAULT_X_RATE_LIMIT_SECONDS, + X_RATE_LIMIT_REMAINING_THRESHOLD, XDeleteTweetsStartResponse, XProgressInfo, ResponseData, @@ -120,10 +121,45 @@ export class XAccountController extends BaseAccountController { Math.floor(Date.now() / 1000) + DEFAULT_X_RATE_LIMIT_SECONDS, ); } + } else { + this.observeDeleteRateLimitHeaders(details); } }); } + // Only the delete mutations: peripheral endpoints run out of quota first + // and must not stall a delete run. + private static readonly DELETE_OPERATION_URL = + /\/graphql\/[^/?]+\/(DeleteTweet|DeleteRetweet|UnfavoriteTweet|DeleteBookmark)(\?|$)/; + + // Mark the account rate limited when a delete mutation reports little quota + // left, so the delete loop pauses before provoking a 429. + observeDeleteRateLimitHeaders(details: { + url: string; + statusCode: number; + responseHeaders?: Record; + }): void { + if (details.statusCode !== 200 || !details.responseHeaders) { + return; + } + if (!XAccountController.DELETE_OPERATION_URL.test(details.url)) { + return; + } + + const remaining = Number(details.responseHeaders["x-rate-limit-remaining"]); + const reset = Number(details.responseHeaders["x-rate-limit-reset"]); + if (Number.isNaN(remaining) || !reset) { + return; + } + + if (remaining <= X_RATE_LIMIT_REMAINING_THRESHOLD) { + log.info( + `XAccountController: ${remaining} requests left before X rate limits ${details.url}, pausing until ${reset}`, + ); + this.markRateLimited(reset); + } + } + // A GraphQL request names its operation identifier and then its operation: // https://x.com/i/api/graphql// private observeGraphqlOperation(url: string): void { diff --git a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.test.ts b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.test.ts index 429b5742..a7d6a724 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.test.ts @@ -110,6 +110,37 @@ describe("helpers_shared.ts", () => { expect(vm.waitForRateLimit).toHaveBeenCalled(); }); + it("should wait out a pending rate limit before sending the delete", async () => { + const mockDeleteFn = vi.fn().mockResolvedValue(200); + vi.spyOn(vm, "waitForRateLimit").mockResolvedValue(undefined); + mockElectron.X.isRateLimited.mockResolvedValue({ + isRateLimited: true, + rateLimitReset: Date.now() + 1000, + }); + + const result = await DeleteHelpers.deleteContentRetryLoop( + vm, + mockDeleteFn, + ); + + expect(result).toEqual({ success: true, statusCode: 200 }); + expect(vm.waitForRateLimit).toHaveBeenCalledTimes(1); + expect(mockDeleteFn).toHaveBeenCalledTimes(1); + expect( + vi.mocked(vm.waitForRateLimit).mock.invocationCallOrder[0], + ).toBeLessThan(mockDeleteFn.mock.invocationCallOrder[0]); + }); + + it("should not wait when no rate limit is pending", async () => { + const mockDeleteFn = vi.fn().mockResolvedValue(200); + vi.spyOn(vm, "waitForRateLimit").mockResolvedValue(undefined); + + await DeleteHelpers.deleteContentRetryLoop(vm, mockDeleteFn); + + expect(mockElectron.X.isRateLimited).toHaveBeenCalledWith(1); + expect(vm.waitForRateLimit).not.toHaveBeenCalled(); + }); + it("should return failure after 3 unsuccessful tries", async () => { const mockDeleteFn = vi.fn().mockResolvedValue(500); vi.spyOn(vm, "sleep").mockResolvedValue(undefined); diff --git a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.ts b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.ts index 848dfcc2..adb6b584 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_delete/helpers_shared.ts @@ -44,6 +44,12 @@ export async function deleteContentRetryLoop( let statusCode = 0; for (let tries = 0; tries < 3; tries++) { + // Pause if the previous delete left little quota in the window + vm.rateLimitInfo = await window.electron.X.isRateLimited(vm.account.id); + if (vm.rateLimitInfo.isRateLimited) { + await vm.waitForRateLimit(); + } + statusCode = await deleteFunction(); if (statusCode === 200) { diff --git a/src/shared_types/x.ts b/src/shared_types/x.ts index e33a4224..4121374b 100644 --- a/src/shared_types/x.ts +++ b/src/shared_types/x.ts @@ -186,6 +186,9 @@ export function emptyXRateLimitInfo(): XRateLimitInfo { // How long to wait out a rate limit X reports without saying when it lifts export const DEFAULT_X_RATE_LIMIT_SECONDS = 900; +// Pause deletes when X says this many requests or fewer remain in the window +export const X_RATE_LIMIT_REMAINING_THRESHOLD = 2; + export type XIndexTimelineStats = { // Timeline responses recognized as one of X's timeline operations recognizedResponses: number;