diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index c6db54e..bca279c 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -5,6 +5,7 @@ LinkupInsufficientCreditError, LinkupInvalidRequestError, LinkupNoResultError, + LinkupTimeoutError, LinkupTooManyRequestsError, LinkupUnknownError, ) @@ -33,6 +34,7 @@ "LinkupSearchTextResult", "LinkupSource", "LinkupSourcedAnswer", + "LinkupTimeoutError", "LinkupTooManyRequestsError", "LinkupUnknownError", "__version__", diff --git a/src/linkup/_client.py b/src/linkup/_client.py index 3c89640..bbf4ba7 100644 --- a/src/linkup/_client.py +++ b/src/linkup/_client.py @@ -16,6 +16,7 @@ LinkupInsufficientCreditError, LinkupInvalidRequestError, LinkupNoResultError, + LinkupTimeoutError, LinkupTooManyRequestsError, LinkupUnknownError, ) @@ -71,6 +72,7 @@ def search( max_results: int | None = None, include_inline_citations: bool | None = None, include_sources: bool | None = None, + timeout: float | None = None, ) -> Any: # noqa: ANN401 """Perform a web search using the Linkup API `search` endpoint. @@ -101,6 +103,8 @@ def search( answer should include inline citations. include_sources: If output_type is "structured", indicate whether the answer should include sources. This will modify the schema of the structured response. + timeout: The timeout for the HTTP request, in seconds. If None, the request will have + no timeout. Returns: The Linkup API search result, which can have different types based on the parameters: @@ -120,6 +124,7 @@ def search( LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. LinkupNoResultError: If the search query did not yield any result. + LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool | int | list[str]] = self._get_search_params( query=query, @@ -136,12 +141,17 @@ def search( include_sources=include_sources, ) - response: httpx.Response = self._request( - method="POST", - url="/search", - json=params, - timeout=None, - ) + try: + response: httpx.Response = self._request( + method="POST", + url="/search", + json=params, + timeout=timeout, + ) + except httpx.TimeoutException as e: + raise LinkupTimeoutError( + "The request to the Linkup API timed out. Try increasing the timeout value." + ) from e if response.status_code != 200: self._raise_linkup_error(response=response) @@ -166,6 +176,7 @@ async def async_search( max_results: int | None = None, include_inline_citations: bool | None = None, include_sources: bool | None = None, + timeout: float | None = None, ) -> Any: # noqa: ANN401 """Asynchronously perform a web search using the Linkup API `search` endpoint. @@ -196,6 +207,8 @@ async def async_search( answer should include inline citations. include_sources: If output_type is "structured", indicate whether the answer should include sources. This will modify the schema of the structured response. + timeout: The timeout for the HTTP request, in seconds. If None, the request will have + no timeout. Returns: The Linkup API search result, which can have different types based on the parameters: @@ -215,6 +228,7 @@ async def async_search( LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. LinkupNoResultError: If the search query did not yield any result. + LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool | int | list[str]] = self._get_search_params( query=query, @@ -231,12 +245,17 @@ async def async_search( include_sources=include_sources, ) - response: httpx.Response = await self._async_request( - method="POST", - url="/search", - json=params, - timeout=None, - ) + try: + response: httpx.Response = await self._async_request( + method="POST", + url="/search", + json=params, + timeout=timeout, + ) + except httpx.TimeoutException as e: + raise LinkupTimeoutError( + "The request to the Linkup API timed out. Try increasing the timeout value." + ) from e if response.status_code != 200: self._raise_linkup_error(response=response) @@ -253,6 +272,7 @@ def fetch( include_raw_html: bool | None = None, render_js: bool | None = None, extract_images: bool | None = None, + timeout: float | None = None, ) -> LinkupFetchResponse: """Fetch the content of a web page using the Linkup API `fetch` endpoint. @@ -266,6 +286,8 @@ def fetch( render_js: Whether the API should render the JavaScript of the webpage. extract_images: Whether the API should extract images from the webpage and return them in the response. + timeout: The timeout for the HTTP request, in seconds. If None, the request will have + no timeout. Returns: The response of the web page fetch, containing the web page content. @@ -273,6 +295,7 @@ def fetch( Raises: LinkupInvalidRequestError: If the provided URL is not valid. LinkupFailedFetchError: If the provided URL is not found or can't be fetched. + LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool] = self._get_fetch_params( url=url, @@ -281,12 +304,17 @@ def fetch( extract_images=extract_images, ) - response: httpx.Response = self._request( - method="POST", - url="/fetch", - json=params, - timeout=None, - ) + try: + response: httpx.Response = self._request( + method="POST", + url="/fetch", + json=params, + timeout=timeout, + ) + except httpx.TimeoutException as e: + raise LinkupTimeoutError( + "The request to the Linkup API timed out. Try increasing the timeout value." + ) from e if response.status_code != 200: self._raise_linkup_error(response=response) @@ -298,6 +326,7 @@ async def async_fetch( include_raw_html: bool | None = None, render_js: bool | None = None, extract_images: bool | None = None, + timeout: float | None = None, ) -> LinkupFetchResponse: """Asynchronously fetch the content of a web page using the Linkup API `fetch` endpoint. @@ -311,6 +340,8 @@ async def async_fetch( render_js: Whether the API should render the JavaScript of the webpage. extract_images: Whether the API should extract images from the webpage and return them in the response. + timeout: The timeout for the HTTP request, in seconds. If None, the request will have + no timeout. Returns: The response of the web page fetch, containing the web page content. @@ -318,6 +349,7 @@ async def async_fetch( Raises: LinkupInvalidRequestError: If the provided URL is not valid. LinkupFailedFetchError: If the provided URL is not found or can't be fetched. + LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool] = self._get_fetch_params( url=url, @@ -326,12 +358,17 @@ async def async_fetch( extract_images=extract_images, ) - response: httpx.Response = await self._async_request( - method="POST", - url="/fetch", - json=params, - timeout=None, - ) + try: + response: httpx.Response = await self._async_request( + method="POST", + url="/fetch", + json=params, + timeout=timeout, + ) + except httpx.TimeoutException as e: + raise LinkupTimeoutError( + "The request to the Linkup API timed out. Try increasing the timeout value." + ) from e if response.status_code != 200: self._raise_linkup_error(response=response) diff --git a/src/linkup/_errors.py b/src/linkup/_errors.py index 5f8c6c4..ab22f4f 100644 --- a/src/linkup/_errors.py +++ b/src/linkup/_errors.py @@ -57,6 +57,15 @@ class LinkupFailedFetchError(Exception): pass +class LinkupTimeoutError(Exception): + """Timeout error, raised when the HTTP request to the Linkup API times out. + + It is raised when a timeout is specified and the request exceeds the given duration. + """ + + pass + + class LinkupUnknownError(Exception): """Unknown error, raised when the Linkup API returns an unknown status code.""" diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index 26f0967..ec3d396 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -2,6 +2,7 @@ from datetime import date from typing import Any +import httpx import pytest from httpx import Response from pydantic import BaseModel @@ -21,6 +22,7 @@ LinkupSearchTextResult, LinkupSource, LinkupSourcedAnswer, + LinkupTimeoutError, LinkupTooManyRequestsError, LinkupUnknownError, ) @@ -98,6 +100,17 @@ class Company(BaseModel): b'{"results": []}', LinkupSearchResults(results=[]), ), + ( + { + "query": "query with timeout", + "depth": "standard", + "output_type": "searchResults", + "timeout": 30.0, + }, + {"q": "query with timeout", "depth": "standard", "outputType": "searchResults"}, + b'{"results": []}', + LinkupSearchResults(results=[]), + ), ( {"query": "query", "depth": "standard", "output_type": "sourcedAnswer"}, {"q": "query", "depth": "standard", "outputType": "sourcedAnswer"}, @@ -270,11 +283,12 @@ def test_search( ) search_response: Any = client.search(**search_kwargs) + expected_timeout = search_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", url="/search", json=expected_request_params, - timeout=None, + timeout=expected_timeout, ) assert search_response == expected_search_response @@ -307,11 +321,12 @@ async def test_async_search( ) search_response: Any = await client.async_search(**search_kwargs) + expected_timeout = search_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", url="/search", json=expected_request_params, - timeout=None, + timeout=expected_timeout, ) assert search_response == expected_search_response @@ -478,6 +493,35 @@ async def test_async_search_error( request_mock.assert_called_once() +def test_search_timeout( + mocker: MockerFixture, + client: LinkupClient, +) -> None: + mocker.patch( + "linkup._client.LinkupClient._request", + side_effect=httpx.ReadTimeout("Request timed out"), + ) + + with pytest.raises(LinkupTimeoutError): + client.search(query="query", depth="standard", output_type="searchResults", timeout=1.0) + + +@pytest.mark.asyncio +async def test_async_search_timeout( + mocker: MockerFixture, + client: LinkupClient, +) -> None: + mocker.patch( + "linkup._client.LinkupClient._async_request", + side_effect=httpx.ReadTimeout("Request timed out"), + ) + + with pytest.raises(LinkupTimeoutError): + await client.async_search( + query="query", depth="standard", output_type="searchResults", timeout=1.0 + ) + + test_fetch_parameters = [ ( {"url": "https://example.com"}, @@ -501,6 +545,12 @@ async def test_async_search_error( b'{"markdown": "#Some web page content", "rawHtml": "..."}', LinkupFetchResponse(markdown="#Some web page content", raw_html="..."), ), + ( + {"url": "https://example.com", "timeout": 15.0}, + {"url": "https://example.com"}, + b'{"markdown": "Some web page content"}', + LinkupFetchResponse(markdown="Some web page content", raw_html=None), + ), ] @@ -530,11 +580,12 @@ def test_fetch( ) fetch_response: LinkupFetchResponse = client.fetch(**fetch_kwargs) + expected_timeout = fetch_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", url="/fetch", json=expected_request_params, - timeout=None, + timeout=expected_timeout, ) assert fetch_response == expected_fetch_response @@ -566,11 +617,12 @@ async def test_async_fetch( ) fetch_response: LinkupFetchResponse = await client.async_fetch(**fetch_kwargs) + expected_timeout = fetch_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", url="/fetch", json=expected_request_params, - timeout=None, + timeout=expected_timeout, ) assert fetch_response == expected_fetch_response @@ -657,3 +709,30 @@ async def test_async_fetch_error( with pytest.raises(expected_exception): await client.async_fetch(url="https://example.com") request_mock.assert_called_once() + + +def test_fetch_timeout( + mocker: MockerFixture, + client: LinkupClient, +) -> None: + mocker.patch( + "linkup._client.LinkupClient._request", + side_effect=httpx.ReadTimeout("Request timed out"), + ) + + with pytest.raises(LinkupTimeoutError): + client.fetch(url="https://example.com", timeout=1.0) + + +@pytest.mark.asyncio +async def test_async_fetch_timeout( + mocker: MockerFixture, + client: LinkupClient, +) -> None: + mocker.patch( + "linkup._client.LinkupClient._async_request", + side_effect=httpx.ReadTimeout("Request timed out"), + ) + + with pytest.raises(LinkupTimeoutError): + await client.async_fetch(url="https://example.com", timeout=1.0)