From 23060b6fd8a83a675d5795398572908def955580 Mon Sep 17 00:00:00 2001 From: Lucius Date: Fri, 31 Jul 2026 15:51:40 +0800 Subject: [PATCH] fix: retry unspecified GraphQL queries with live IDs --- tests/test_client.py | 80 ++++++++++++++++++++++++++++++++++++++++++ twitter_cli/client.py | 30 +++++++++++++--- twitter_cli/graphql.py | 4 +-- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index c1393d3..16360d5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -210,6 +210,86 @@ def test_searchtimeline_fallback_query_id_regression(self): """Keep SearchTimeline fallback aligned with the live operation after issue #39.""" assert FALLBACK_QUERY_IDS["SearchTimeline"] == "VhUd6vHVmLBcw0uX-6jMLA" + def test_home_timeline_fallback_query_ids(self): + """Keep home timeline fallbacks aligned with the current web bundle.""" + assert FALLBACK_QUERY_IDS["HomeTimeline"] == "3b9_7tltt0hJRef-xm_3sw" + assert FALLBACK_QUERY_IDS["HomeLatestTimeline"] == "m1G65W9TS1-g-AllrKKYDQ" + + +class TestStaleQueryRetry: + def test_graphql_get_retries_query_unspecified_with_live_id(self): + client = TwitterClient.__new__(TwitterClient) + fallback = FALLBACK_QUERY_IDS["HomeTimeline"] + urls = [] + + def api_get(url): + urls.append(url) + if len(urls) == 1: + raise TwitterAPIError(0, "Twitter API returned errors: Query: Unspecified") + return {"data": {"home": {}}} + + client._api_get = api_get + + with ( + patch( + "twitter_cli.client._resolve_query_id", + side_effect=[fallback, "live-query-id"], + ) as resolve, + patch("twitter_cli.client._invalidate_query_id") as invalidate, + ): + result = client._graphql_get("HomeTimeline", {"count": 3}, FEATURES) + + assert result == {"data": {"home": {}}} + assert fallback in urls[0] + assert "live-query-id" in urls[1] + assert resolve.call_count == 2 + assert resolve.call_args_list[1].kwargs["prefer_fallback"] is False + invalidate.assert_called_once_with("HomeTimeline") + + def test_graphql_post_retries_query_unspecified_with_live_id(self): + client = TwitterClient.__new__(TwitterClient) + fallback = FALLBACK_QUERY_IDS["CreateTweet"] + requests = [] + + def api_request(url, method="GET", body=None): + requests.append((url, method, body)) + if len(requests) == 1: + raise TwitterAPIError(0, "Twitter API returned errors: Query: Unspecified") + return {"data": {"create_tweet": {}}} + + client._api_request = api_request + + with ( + patch( + "twitter_cli.client._resolve_query_id", + side_effect=[fallback, "live-query-id"], + ), + patch("twitter_cli.client._invalidate_query_id") as invalidate, + ): + result = client._graphql_post("CreateTweet", {"tweet_text": "hello"}, FEATURES) + + assert result == {"data": {"create_tweet": {}}} + assert requests[0][2]["queryId"] == fallback + assert requests[1][2]["queryId"] == "live-query-id" + invalidate.assert_called_once_with("CreateTweet") + + def test_graphql_get_does_not_retry_unrelated_status_zero_error(self): + client = TwitterClient.__new__(TwitterClient) + fallback = FALLBACK_QUERY_IDS["HomeTimeline"] + client._api_get = MagicMock( + side_effect=TwitterAPIError(0, "Twitter API network error: timeout") + ) + + with ( + patch("twitter_cli.client._resolve_query_id", return_value=fallback) as resolve, + patch("twitter_cli.client._invalidate_query_id") as invalidate, + pytest.raises(TwitterAPIError), + ): + client._graphql_get("HomeTimeline", {"count": 3}, FEATURES) + + resolve.assert_called_once() + invalidate.assert_not_called() + # ── _best_chrome_target ────────────────────────────────────────────────── diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..1238ff6 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -130,6 +130,25 @@ def _url_fetch(url, headers=None): return resp.text +def _is_stale_query_error(exc): + # type: (TwitterAPIError) -> bool + """Return whether an API error indicates a stale persisted query ID.""" + if exc.status_code in (404, 422): + return True + if exc.status_code != 0: + return False + message = exc.message.lower() + return any( + marker in message + for marker in ( + "query: unspecified", + "persistedquerynotfound", + "persisted query not found", + "query not found", + ) + ) + + # ── TwitterClient ──────────────────────────────────────────────────────── @@ -906,9 +925,10 @@ def _graphql_get(self, operation_name, variables, features, field_toggles=None): try: return self._api_get(url) except TwitterAPIError as exc: - # Fallback query IDs can go stale. Retry with live lookup if 404/422. - if exc.status_code in (404, 422) and using_fallback: - logger.info("Retrying %s with live queryId after %d", operation_name, exc.status_code) + # Stale IDs may be reported as HTTP 404/422 or as a GraphQL + # "Query: Unspecified" error inside an otherwise successful response. + if _is_stale_query_error(exc) and using_fallback: + logger.info("Retrying %s with live queryId after stale-query response", operation_name) _invalidate_query_id(operation_name) refreshed_query_id = _resolve_query_id(operation_name, prefer_fallback=False, url_fetch_fn=_url_fetch) retry_url = _build_graphql_url(refreshed_query_id, operation_name, variables, features, field_toggles) @@ -932,8 +952,8 @@ def _do_post(qid): try: return _do_post(query_id) except TwitterAPIError as exc: - if exc.status_code in (404, 422) and using_fallback: - logger.info("Retrying POST %s with live queryId after %d", operation_name, exc.status_code) + if _is_stale_query_error(exc) and using_fallback: + logger.info("Retrying POST %s with live queryId after stale-query response", operation_name) _invalidate_query_id(operation_name) refreshed = _resolve_query_id(operation_name, prefer_fallback=False, url_fetch_fn=_url_fetch) return _do_post(refreshed) diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d34ea35..ea128f7 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -27,8 +27,8 @@ # ── Fallback (hardcoded) queryIds ──────────────────────────────────────── FALLBACK_QUERY_IDS = { - "HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ", - "HomeLatestTimeline": "BKB7oi212Fi7kQtCBGE4zA", + "HomeTimeline": "3b9_7tltt0hJRef-xm_3sw", + "HomeLatestTimeline": "m1G65W9TS1-g-AllrKKYDQ", "UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A", "UserTweets": "q6xj5bs0hapm9309hexA_g", "TweetDetail": "xd_EMdYvB9hfZsZ6Idri0w",