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
80 changes: 80 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────

Expand Down
30 changes: 25 additions & 5 deletions twitter_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────


Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions twitter_cli/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading