Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/account_x/__tests__/unit/controller.core.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
36 changes: 36 additions & 0 deletions src/account_x/x_account_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
XIndexTimelineStats,
emptyXIndexTimelineStats,
DEFAULT_X_RATE_LIMIT_SECONDS,
X_RATE_LIMIT_REMAINING_THRESHOLD,
XDeleteTweetsStartResponse,
XProgressInfo,
ResponseData,
Expand Down Expand Up @@ -120,10 +121,45 @@ export class XAccountController extends BaseAccountController<XProgress> {
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<string, string | string[]>;
}): 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/<identifier>/<operation>
private observeGraphqlOperation(url: string): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/shared_types/x.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down