From 72f74dfecd3e894e9c5354ce7321fc779be2fa46 Mon Sep 17 00:00:00 2001 From: "seer-by-sentry[bot]" <157164994+seer-by-sentry[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 06:22:50 +0000 Subject: [PATCH] feat: handle LinkedEvents API timeouts for popular keywords Add graceful degradation for LinkedEvents API timeout handling in popularKultusKeywords resolver. The resolver now continues processing remaining keyword sets even if one request fails due to timeout or connection errors. Refs: PT-1990 --- graphene_linked_events/schema.py | 30 ++++-- graphene_linked_events/tests/test_api.py | 111 +++++++++++++++++++++++ 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/graphene_linked_events/schema.py b/graphene_linked_events/schema.py index 74095d90..54d0a829 100644 --- a/graphene_linked_events/schema.py +++ b/graphene_linked_events/schema.py @@ -3,6 +3,7 @@ import math from types import SimpleNamespace +import requests from django.conf import settings from django.core.exceptions import PermissionDenied from django.db import transaction @@ -661,14 +662,29 @@ def resolve_popular_kultus_keywords(parent, info, **kwargs): ] keywords = {} + # Graceful degradation: Continue processing remaining keyword sets + # even if one API request fails due to timeout or other errors. + # This prevents the entire query from failing when the LinkedEvents API + # is slow or experiencing issues. for set_id in set_ids: - response = api_client.retrieve( - "keyword_set", set_id, params={"include": "keywords"} - ) - response = json2obj(format_response(response)) - for keyword in response.keywords: - if show_all_keywords or keyword.n_events > 0: - keywords[keyword.id] = keyword + try: + response = api_client.retrieve( + "keyword_set", set_id, params={"include": "keywords"} + ) + response = json2obj(format_response(response)) + for keyword in response.keywords: + if show_all_keywords or keyword.n_events > 0: + keywords[keyword.id] = keyword + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.Timeout, + requests.exceptions.RequestException, + ) as e: + # Log the error but continue processing other keyword sets + logger.warning( + f"Failed to fetch keyword set '{set_id}' from LinkedEvents API: {e}" + ) + continue unique_keywords = sorted( keywords.values(), key=lambda x: x.n_events, reverse=True diff --git a/graphene_linked_events/tests/test_api.py b/graphene_linked_events/tests/test_api.py index f887722f..9e7ddd34 100644 --- a/graphene_linked_events/tests/test_api.py +++ b/graphene_linked_events/tests/test_api.py @@ -27,6 +27,7 @@ EVENT_DATA, EVENTS_DATA, KEYWORD_SET_DATA, + POPULAR_KEYWORD_SET_DATA, UPDATE_EVENT_DATA, ) from graphene_linked_events.tests.utils import MockResponse @@ -2130,6 +2131,116 @@ def test_get_popular_kultus_keywords( snapshot.assert_match(executed) +def test_get_popular_kultus_keywords_with_timeout(api_client, monkeypatch, settings): + """Test that popularKultusKeywords handles ConnectTimeout gracefully.""" + from requests.exceptions import ConnectTimeout + + def mock_retrieve_with_timeout( + self, resource, id, params=None, is_event_staff=False + ): + # Simulate timeout for the first keyword set + if id == settings.KEYWORD_SET_ID_MAPPING["CATEGORY"]: + raise ConnectTimeout("Connection to api.hel.fi timed out") + # Return valid data for other keyword sets + return MockResponse(status_code=200, json_data=POPULAR_KEYWORD_SET_DATA) + + monkeypatch.setattr( + LinkedEventsApiClient, + "retrieve", + mock_retrieve_with_timeout, + ) + + executed = api_client.execute( + POPULAR_KULTUS_KEYWORDS_QUERY, + variables={"amount": None, "showAllKeywords": True}, + ) + + # Should not error out, should return partial results + assert "errors" not in executed + assert executed["data"]["popularKultusKeywords"] is not None + # Should still have keywords from the other two sets + assert executed["data"]["popularKultusKeywords"]["meta"]["count"] >= 0 + + +def test_get_popular_kultus_keywords_continues_after_timeout( + api_client, monkeypatch, settings +): + """Test that resolver continues processing after one keyword set times out.""" + from requests.exceptions import ConnectTimeout + + call_count = {"count": 0} + set_ids_called = [] + + def mock_retrieve_with_partial_timeout( + self, resource, id, params=None, is_event_staff=False + ): + call_count["count"] += 1 + set_ids_called.append(id) + # Timeout on ADDITIONAL_CRITERIA (middle set) + if id == settings.KEYWORD_SET_ID_MAPPING["ADDITIONAL_CRITERIA"]: + raise ConnectTimeout("Connection timeout") + return MockResponse(status_code=200, json_data=POPULAR_KEYWORD_SET_DATA) + + monkeypatch.setattr( + LinkedEventsApiClient, + "retrieve", + mock_retrieve_with_partial_timeout, + ) + + executed = api_client.execute( + POPULAR_KULTUS_KEYWORDS_QUERY, + variables={"amount": None, "showAllKeywords": True}, + ) + + # All 3 keyword sets should have been attempted + assert call_count["count"] == 3 + assert len(set_ids_called) == 3 + # Should not error + assert "errors" not in executed + # Should return results from the 2 successful requests + assert executed["data"]["popularKultusKeywords"]["meta"]["count"] > 0 + + +def test_get_popular_kultus_keywords_partial_results(api_client, monkeypatch, settings): + """Test partial results when some API requests fail.""" + from requests.exceptions import Timeout + + def mock_retrieve_with_mixed_results( + self, resource, id, params=None, is_event_staff=False + ): + # Fail TARGET_GROUP request + if id == settings.KEYWORD_SET_ID_MAPPING["TARGET_GROUP"]: + raise Timeout("Request timeout") + # Return data with different keywords for other sets + keyword_data = { + **POPULAR_KEYWORD_SET_DATA, + "keywords": [ + { + **POPULAR_KEYWORD_SET_DATA["keywords"][0], + "id": f"keyword:{id}", + } + ], + } + return MockResponse(status_code=200, json_data=keyword_data) + + monkeypatch.setattr( + LinkedEventsApiClient, + "retrieve", + mock_retrieve_with_mixed_results, + ) + + executed = api_client.execute( + POPULAR_KULTUS_KEYWORDS_QUERY, + variables={"amount": None, "showAllKeywords": True}, + ) + + # Should return partial results from successful requests + assert "errors" not in executed + assert executed["data"]["popularKultusKeywords"] is not None + # Should have keywords from 2 out of 3 sets + assert executed["data"]["popularKultusKeywords"]["meta"]["count"] == 2 + + @pytest.mark.parametrize( "status_code,error_cls", [(400, ApiBadRequestError), (404, ObjectDoesNotExistError)],