diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index 88a279e..2b0d902 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -1,13 +1,17 @@ from ._client import LinkupClient from ._errors import ( LinkupAuthenticationError, + LinkupBudgetLimitExceededError, LinkupFailedFetchError, LinkupFetchResponseTooLargeError, + LinkupFetchUnsupportedContentTypeError, LinkupFetchUrlIsFileError, LinkupInsufficientCreditError, LinkupInvalidRequestError, LinkupNoResultError, LinkupPaymentRequiredError, + LinkupTaskNotFoundError, + LinkupTasksQueueLimitExceededError, LinkupTimeoutError, LinkupTooManyRequestsError, LinkupUnknownError, @@ -38,11 +42,13 @@ # Aliases to allow usage like `import linkup` and `client = linkup.Client(...)` AuthenticationError = LinkupAuthenticationError +BudgetLimitExceededError = LinkupBudgetLimitExceededError Client = LinkupClient FailedFetchError = LinkupFailedFetchError FetchImageExtraction = LinkupFetchImageExtraction FetchResponse = LinkupFetchResponse FetchResponseTooLargeError = LinkupFetchResponseTooLargeError +FetchUnsupportedContentTypeError = LinkupFetchUnsupportedContentTypeError FetchTask = LinkupFetchTask FetchTaskInput = LinkupFetchTaskInput FetchUrlIsFileError = LinkupFetchUrlIsFileError @@ -62,16 +68,19 @@ Source = LinkupSource SourcedAnswer = LinkupSourcedAnswer Task = LinkupTask +TaskNotFoundError = LinkupTaskNotFoundError TaskInput = LinkupTaskInput TaskMetadata = LinkupTaskMetadata TaskQuota = LinkupTaskQuota TasksPage = LinkupTasksPage +TasksQueueLimitExceededError = LinkupTasksQueueLimitExceededError TimeoutError = LinkupTimeoutError # noqa: A001 TooManyRequestsError = LinkupTooManyRequestsError UnknownError = LinkupUnknownError __all__ = [ "AuthenticationError", + "BudgetLimitExceededError", "Client", "FailedFetchError", "FetchImageExtraction", @@ -79,10 +88,12 @@ "FetchResponseTooLargeError", "FetchTask", "FetchTaskInput", + "FetchUnsupportedContentTypeError", "FetchUrlIsFileError", "InsufficientCreditError", "InvalidRequestError", "LinkupAuthenticationError", + "LinkupBudgetLimitExceededError", "LinkupClient", "LinkupFailedFetchError", "LinkupFetchImageExtraction", @@ -90,6 +101,7 @@ "LinkupFetchResponseTooLargeError", "LinkupFetchTask", "LinkupFetchTaskInput", + "LinkupFetchUnsupportedContentTypeError", "LinkupFetchUrlIsFileError", "LinkupInsufficientCreditError", "LinkupInvalidRequestError", @@ -109,8 +121,10 @@ "LinkupTask", "LinkupTaskInput", "LinkupTaskMetadata", + "LinkupTaskNotFoundError", "LinkupTaskQuota", "LinkupTasksPage", + "LinkupTasksQueueLimitExceededError", "LinkupTimeoutError", "LinkupTooManyRequestsError", "LinkupUnknownError", @@ -130,8 +144,10 @@ "Task", "TaskInput", "TaskMetadata", + "TaskNotFoundError", "TaskQuota", "TasksPage", + "TasksQueueLimitExceededError", "TimeoutError", "TooManyRequestsError", "UnknownError", diff --git a/src/linkup/_client.py b/src/linkup/_client.py index b31ced7..0547be5 100644 --- a/src/linkup/_client.py +++ b/src/linkup/_client.py @@ -12,13 +12,17 @@ from ._errors import ( LinkupAuthenticationError, + LinkupBudgetLimitExceededError, LinkupFailedFetchError, LinkupFetchResponseTooLargeError, + LinkupFetchUnsupportedContentTypeError, LinkupFetchUrlIsFileError, LinkupInsufficientCreditError, LinkupInvalidRequestError, LinkupNoResultError, LinkupPaymentRequiredError, + LinkupTaskNotFoundError, + LinkupTasksQueueLimitExceededError, LinkupTimeoutError, LinkupTooManyRequestsError, LinkupUnknownError, @@ -154,6 +158,7 @@ def search( JSON schema when output_type is "structured". LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. + LinkupBudgetLimitExceededError: If the API key has reached its configured budget limit. LinkupNoResultError: If the search query did not yield any result. LinkupTimeoutError: If the request times out. """ @@ -253,6 +258,7 @@ async def async_search( JSON schema when output_type is "structured". LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. + LinkupBudgetLimitExceededError: If the API key has reached its configured budget limit. LinkupNoResultError: If the search query did not yield any result. LinkupTimeoutError: If the request times out. """ @@ -519,7 +525,7 @@ def get_research(self, research_id: str, timeout: float | None = None) -> Linkup The requested research task. Raises: - LinkupInvalidRequestError: If the research identifier is invalid or unknown. + LinkupTaskNotFoundError: If the research identifier does not match an existing task. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupTimeoutError: If the request times out. """ @@ -545,7 +551,7 @@ async def async_get_research( The requested research task. Raises: - LinkupInvalidRequestError: If the research identifier is invalid or unknown. + LinkupTaskNotFoundError: If the research identifier does not match an existing task. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupTimeoutError: If the request times out. """ @@ -577,6 +583,7 @@ def create_tasks( LinkupInvalidRequestError: If the task payload is invalid. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. + LinkupTasksQueueLimitExceededError: If too many tasks are already pending or processing. LinkupTimeoutError: If the request times out. """ response = self._request( @@ -609,6 +616,7 @@ async def async_create_tasks( LinkupInvalidRequestError: If the task payload is invalid. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupInsufficientCreditError: If you have run out of credit. + LinkupTasksQueueLimitExceededError: If too many tasks are already pending or processing. LinkupTimeoutError: If the request times out. """ response = await self._async_request( @@ -627,8 +635,12 @@ def list_tasks( page_size: int | None = None, sort_by: Literal["createdAt", "updatedAt"] | None = None, sort_direction: Literal["asc", "desc"] | None = None, - status: Literal["pending", "processing", "completed", "failed"] | None = None, - task_type: Literal["search", "fetch", "research"] | None = None, + status: Literal["pending", "processing", "completed", "failed"] + | list[Literal["pending", "processing", "completed", "failed"]] + | None = None, + task_type: Literal["search", "fetch", "research"] + | list[Literal["search", "fetch", "research"]] + | None = None, timeout: float | None = None, ) -> LinkupTasksPage: """List tasks for the authenticated organization. @@ -640,8 +652,8 @@ def list_tasks( API default is used. sort_direction: The sort direction, either "asc" or "desc". If None, the Linkup API default is used. - status: A task status to filter by. If None, no status filter is sent. - task_type: A task type to filter by. If None, no task type filter is sent. + status: One or more task statuses to filter by. If None, no status filter is sent. + task_type: One or more task types to filter by. If None, no task type filter is sent. timeout: The timeout for the HTTP request, in seconds. If None, the request will have no timeout. @@ -676,8 +688,12 @@ async def async_list_tasks( page_size: int | None = None, sort_by: Literal["createdAt", "updatedAt"] | None = None, sort_direction: Literal["asc", "desc"] | None = None, - status: Literal["pending", "processing", "completed", "failed"] | None = None, - task_type: Literal["search", "fetch", "research"] | None = None, + status: Literal["pending", "processing", "completed", "failed"] + | list[Literal["pending", "processing", "completed", "failed"]] + | None = None, + task_type: Literal["search", "fetch", "research"] + | list[Literal["search", "fetch", "research"]] + | None = None, timeout: float | None = None, ) -> LinkupTasksPage: """Asynchronously list tasks for the authenticated organization. @@ -689,8 +705,8 @@ async def async_list_tasks( API default is used. sort_direction: The sort direction, either "asc" or "desc". If None, the Linkup API default is used. - status: A task status to filter by. If None, no status filter is sent. - task_type: A task type to filter by. If None, no task type filter is sent. + status: One or more task statuses to filter by. If None, no status filter is sent. + task_type: One or more task types to filter by. If None, no task type filter is sent. timeout: The timeout for the HTTP request, in seconds. If None, the request will have no timeout. @@ -731,7 +747,7 @@ def get_task(self, task_id: str, timeout: float | None = None) -> LinkupTask: The requested task, parsed according to its task type. Raises: - LinkupInvalidRequestError: If the task identifier is invalid or unknown. + LinkupTaskNotFoundError: If the task identifier does not match an existing task. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupTimeoutError: If the request times out. """ @@ -755,7 +771,7 @@ async def async_get_task(self, task_id: str, timeout: float | None = None) -> Li The requested task, parsed according to its task type. Raises: - LinkupInvalidRequestError: If the task identifier is invalid or unknown. + LinkupTaskNotFoundError: If the task identifier does not match an existing task. LinkupAuthenticationError: If the Linkup API key is invalid. LinkupTimeoutError: If the request times out. """ @@ -797,7 +813,8 @@ def fetch( LinkupInvalidRequestError: If the provided URL is not valid. LinkupFailedFetchError: If the provided URL is not found or can't be fetched. LinkupFetchResponseTooLargeError: If the fetch response is too large. - LinkupFetchUrlIsFileError: If the provided URL points to a file and not a web page. + LinkupFetchUnsupportedContentTypeError: If the URL resolves to an unsupported content + type. LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool] = self._get_fetch_params( @@ -846,7 +863,8 @@ async def async_fetch( LinkupInvalidRequestError: If the provided URL is not valid. LinkupFailedFetchError: If the provided URL is not found or can't be fetched. LinkupFetchResponseTooLargeError: If the fetch response is too large. - LinkupFetchUrlIsFileError: If the provided URL points to a file and not a web page. + LinkupFetchUnsupportedContentTypeError: If the URL resolves to an unsupported content + type. LinkupTimeoutError: If the request times out. """ params: dict[str, str | bool] = self._get_fetch_params( @@ -1085,6 +1103,12 @@ def _raise_linkup_error(self, response: httpx.Response) -> None: "The provided URL's response is too large to be processed.\n" f"Original error message: {error_msg}." ) + if code == "FETCH_UNSUPPORTED_CONTENT_TYPE": + raise LinkupFetchUnsupportedContentTypeError( + "The Linkup API returned an unsupported content type error (400). " + "The provided URL does not point to a supported web page content type.\n" + f"Original error message: {error_msg}." + ) if code == "FETCH_URL_IS_FILE": raise LinkupFetchUrlIsFileError( "The Linkup API returned a fetch URL is file error (400). " @@ -1110,6 +1134,17 @@ def _raise_linkup_error(self, response: httpx.Response) -> None: "key is valid.\n" f"Original error message: {error_msg}." ) + if response.status_code == 404: + if code == "TASK_NOT_FOUND": + raise LinkupTaskNotFoundError( + "The Linkup API returned a task not found error (404). " + "The requested task identifier does not exist.\n" + f"Original error message: {error_msg}." + ) + raise LinkupUnknownError( + f"The Linkup API returned an unknown error (404).\n" + f"Original error message: ({error_msg})." + ) if response.status_code == 429: if code == "INSUFFICIENT_FUNDS_CREDITS": raise LinkupInsufficientCreditError( @@ -1117,6 +1152,18 @@ def _raise_linkup_error(self, response: httpx.Response) -> None: "you haven't exhausted your credits.\n" f"Original error message: {error_msg}." ) + if code == "EXCEED_BUDGET_LIMIT": + raise LinkupBudgetLimitExceededError( + "The Linkup API returned a budget limit exceeded error (429). " + "The API key has reached its configured budget limit.\n" + f"Original error message: {error_msg}." + ) + if code == "TASKS_QUEUE_LIMIT_EXCEEDED": + raise LinkupTasksQueueLimitExceededError( + "The Linkup API returned a tasks queue limit exceeded error (429). " + "Too many tasks are already pending or processing.\n" + f"Original error message: {error_msg}." + ) if code == "TOO_MANY_REQUESTS": raise LinkupTooManyRequestsError( "The Linkup API returned a too many requests error (429). Make sure " @@ -1256,8 +1303,12 @@ def _get_task_list_params( page_size: int | None, sort_by: Literal["createdAt", "updatedAt"] | None, sort_direction: Literal["asc", "desc"] | None, - status: Literal["pending", "processing", "completed", "failed"] | None, - task_type: Literal["search", "fetch", "research"] | None, + status: Literal["pending", "processing", "completed", "failed"] + | list[Literal["pending", "processing", "completed", "failed"]] + | None, + task_type: Literal["search", "fetch", "research"] + | list[Literal["search", "fetch", "research"]] + | None, ) -> dict[str, Any]: params = self._get_paginated_params( page=page, diff --git a/src/linkup/_errors.py b/src/linkup/_errors.py index c2c7625..7bc2d75 100644 --- a/src/linkup/_errors.py +++ b/src/linkup/_errors.py @@ -76,11 +76,48 @@ class LinkupFetchResponseTooLargeError(Exception): pass -class LinkupFetchUrlIsFileError(Exception): - """Fetch URL is file error, raised when the Linkup API search returns a 400 status code. +class LinkupFetchUnsupportedContentTypeError(Exception): + """Unsupported fetch content type error, raised when the Linkup API returns a 400 status code. - It is returned when the Linkup API can't fetch the URL because it points to a file and not - a web page. + It is returned when the URL resolves to a content type that Linkup cannot convert into a web + page response. + """ + + pass + + +class LinkupFetchUrlIsFileError(LinkupFetchUnsupportedContentTypeError): + """Backward-compatible alias for unsupported fetch content type errors. + + Older Linkup API deployments could report this case as a file URL. Current deployments return + the more general unsupported content type error instead. + """ + + pass + + +class LinkupBudgetLimitExceededError(Exception): + """Budget limit exceeded error, raised when the Linkup API returns a 429 status code. + + It is returned when the API key has reached its configured budget limit. + """ + + pass + + +class LinkupTasksQueueLimitExceededError(Exception): + """Tasks queue limit exceeded error, raised when the Linkup API returns a 429 status code. + + It is returned when too many tasks are already pending or processing for the organization. + """ + + pass + + +class LinkupTaskNotFoundError(Exception): + """Task not found error, raised when the Linkup API returns a 404 status code. + + It is returned when a task or research task identifier does not exist. """ pass diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index c60df83..92941f3 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -435,6 +435,19 @@ async def test_async_search( """, linkup.TooManyRequestsError, ), + ( + 429, + b""" + { + "error": { + "code": "EXCEED_BUDGET_LIMIT", + "message": "The API key has reached its budget limit.", + "details": [] + } + } + """, + linkup.BudgetLimitExceededError, + ), ( 429, b""" @@ -830,13 +843,13 @@ async def test_async_fetch( b""" { "error": { - "code": "FETCH_URL_IS_FILE", - "message": "The URL points to a file rather than a webpage", + "code": "FETCH_UNSUPPORTED_CONTENT_TYPE", + "message": "The URL returned an unsupported content type", "details": [] } } """, - linkup.FetchUrlIsFileError, + linkup.FetchUnsupportedContentTypeError, ), ] @@ -1072,6 +1085,51 @@ def test_create_tasks_research_model(mocker: MockerFixture, client: linkup.Clien assert tasks_response[0].input.reasoning_depth == "S" +def test_create_tasks_queue_limit_error(mocker: MockerFixture, client: linkup.Client) -> None: + mocker.patch( + "httpx.Client.request", + return_value=Response( + status_code=429, + content=b""" + { + "error": { + "code": "TASKS_QUEUE_LIMIT_EXCEEDED", + "message": "Too many pending tasks.", + "details": [] + } + } + """, + ), + ) + + with pytest.raises(linkup.TasksQueueLimitExceededError): + client.create_tasks([linkup.FetchTaskInput(url="https://example.com")]) + + +@pytest.mark.asyncio +async def test_async_create_tasks_queue_limit_error( + mocker: MockerFixture, client: linkup.Client +) -> None: + mocker.patch( + "httpx.AsyncClient.request", + return_value=Response( + status_code=429, + content=b""" + { + "error": { + "code": "TASKS_QUEUE_LIMIT_EXCEEDED", + "message": "Too many pending tasks.", + "details": [] + } + } + """, + ), + ) + + with pytest.raises(linkup.TasksQueueLimitExceededError): + await client.async_create_tasks([linkup.FetchTaskInput(url="https://example.com")]) + + @pytest.mark.asyncio async def test_async_list_research(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( @@ -1142,6 +1200,49 @@ async def test_async_list_research(mocker: MockerFixture, client: linkup.Client) ) +def test_get_task_not_found(mocker: MockerFixture, client: linkup.Client) -> None: + mocker.patch( + "httpx.Client.request", + return_value=Response( + status_code=404, + content=b""" + { + "error": { + "code": "TASK_NOT_FOUND", + "message": "Task task-404 not found.", + "details": [] + } + } + """, + ), + ) + + with pytest.raises(linkup.TaskNotFoundError): + client.get_task("task-404") + + +@pytest.mark.asyncio +async def test_async_get_research_not_found(mocker: MockerFixture, client: linkup.Client) -> None: + mocker.patch( + "httpx.AsyncClient.request", + return_value=Response( + status_code=404, + content=b""" + { + "error": { + "code": "TASK_NOT_FOUND", + "message": "Task research-404 not found.", + "details": [] + } + } + """, + ), + ) + + with pytest.raises(linkup.TaskNotFoundError): + await client.async_get_research("research-404") + + def test_list_tasks(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request", @@ -1199,6 +1300,89 @@ def test_list_tasks(mocker: MockerFixture, client: linkup.Client) -> None: assert tasks_page.quota.in_flight == 1 +def test_list_tasks_with_multiple_filters(mocker: MockerFixture, client: linkup.Client) -> None: + request_mock = mocker.patch( + "httpx.Client.request", + return_value=Response( + status_code=200, + content=b""" + { + "data": [], + "metadata": { + "page": 1, + "pageSize": 10, + "total": 0, + "totalPages": 0 + }, + "quota": { + "inFlight": 0, + "limit": 100 + } + } + """, + ), + ) + + tasks_page = client.list_tasks( + status=["pending", "processing"], + task_type=["search", "research"], + ) + + request_mock.assert_called_once_with( + method="GET", + url="/tasks", + params={ + "status": ["pending", "processing"], + "type": ["search", "research"], + }, + timeout=None, + ) + assert tasks_page.metadata.total == 0 + + +@pytest.mark.asyncio +async def test_async_list_tasks_with_multiple_filters( + mocker: MockerFixture, client: linkup.Client +) -> None: + request_mock = mocker.patch( + "httpx.AsyncClient.request", + return_value=Response( + status_code=200, + content=b""" + { + "data": [], + "metadata": { + "page": 1, + "pageSize": 10, + "total": 0, + "totalPages": 0 + }, + "quota": { + "inFlight": 0, + "limit": 100 + } + } + """, + ), + ) + + tasks_page = await client.async_list_tasks( + status=["pending", "processing"], + task_type=["search", "research"], + ) + + request_mock.assert_called_once_with( + method="GET", + url="/tasks", + params={ + "status": ["pending", "processing"], + "type": ["search", "research"], + }, + timeout=None, + ) + assert tasks_page.metadata.total == 0 + + _402_BODY = b'{"error": {"code": "PAYMENT_REQUIRED", "message": "Pay", "details": []}}' _402_BODY_FULL = (