From fb50209a5f8ae266e3586c7d6217a5fcfe6375d6 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Sat, 11 Dec 2021 23:38:03 -0600 Subject: [PATCH] ping api and tests --- noxfile.py | 10 +- poetry.lock | 17 ++- pyproject.toml | 1 + src/healthchecks_io/__init__.py | 5 + src/healthchecks_io/client/_abstract.py | 116 +++++++++++++- src/healthchecks_io/client/async_client.py | 168 ++++++++++++++++++++- src/healthchecks_io/client/exceptions.py | 12 ++ src/healthchecks_io/client/sync_client.py | 154 ++++++++++++++++++- tests/client/test_abstract.py | 84 ++++++++++- tests/client/test_async.py | 76 ++++++++++ tests/client/test_sync.py | 75 +++++++++ tests/conftest.py | 19 ++- 12 files changed, 719 insertions(+), 18 deletions(-) diff --git a/noxfile.py b/noxfile.py index 3ef4ffb..3c5c61d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -35,6 +35,14 @@ nox.options.sessions = ( "docs-build", ) mypy_type_packages = ("types-croniter", "types-pytz") +test_requirements = ( + "coverage[toml]", + "pytest", + "pygments", + "respx", + "pytest-asyncio", + "pytest-lazy-fixture", +) def activate_virtualenv_in_precommit_hooks(session: Session) -> None: @@ -158,7 +166,7 @@ def bandit(session: Session) -> None: def tests(session: Session) -> None: """Run the test suite.""" session.install(".") - session.install("coverage[toml]", "pytest", "pygments", "respx", "pytest-asyncio") + session.install(*test_requirements) try: session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs) finally: diff --git a/poetry.lock b/poetry.lock index ffe598b..c3a905a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -788,6 +788,17 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-lazy-fixture" +version = "0.6.3" +description = "It helps to use fixtures in pytest.mark.parametrize" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=3.2.5" + [[package]] name = "pytest-mock" version = "3.6.1" @@ -1269,7 +1280,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "92a37835a7713cb65e9fbe3c3500ec600c4c869b6fdcb6c0fc9c35702a01eeb2" +content-hash = "f6a7c25e14e47b3f1ff69a6ab9a3bd23041d8d00f4df1c531c2ed539cac62bb9" [metadata.files] alabaster = [ @@ -1687,6 +1698,10 @@ pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] +pytest-lazy-fixture = [ + {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, + {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, +] pytest-mock = [ {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, diff --git a/pyproject.toml b/pyproject.toml index 4eb7dd7..70d0db4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ pytest_async = "^0.1.1" pytest-asyncio = "^0.16.0" respx = "^0.19.0" pytest-mock = "^3.6.1" +pytest-lazy-fixture = "^0.6.3" [tool.coverage.paths] source = ["src", "*/site-packages"] diff --git a/src/healthchecks_io/__init__.py b/src/healthchecks_io/__init__.py index 5c35c0a..ad1c20b 100644 --- a/src/healthchecks_io/__init__.py +++ b/src/healthchecks_io/__init__.py @@ -7,6 +7,8 @@ from .client.exceptions import BadAPIRequestError # noqa: F401, E402 from .client.exceptions import CheckNotFoundError # noqa: F401, E402 from .client.exceptions import HCAPIAuthError # noqa: F401, E402 from .client.exceptions import HCAPIError # noqa: F401, E402 +from .client.exceptions import HCAPIRateLimitError # noqa: F401, E402 +from .client.exceptions import NonUniqueSlugError # noqa: F401, E402 __all__ = [ "AsyncClient", @@ -15,4 +17,7 @@ __all__ = [ "CheckNotFoundError", "HCAPIAuthError", "HCAPIError", + "CheckNotFoundError", + "HCAPIRateLimitError", + "NonUniqueSlugError", ] diff --git a/src/healthchecks_io/client/_abstract.py b/src/healthchecks_io/client/_abstract.py index c20bf46..a4b0d42 100644 --- a/src/healthchecks_io/client/_abstract.py +++ b/src/healthchecks_io/client/_abstract.py @@ -18,6 +18,8 @@ from .exceptions import BadAPIRequestError from .exceptions import CheckNotFoundError from .exceptions import HCAPIAuthError from .exceptions import HCAPIError +from .exceptions import HCAPIRateLimitError +from .exceptions import NonUniqueSlugError class AbstractClient(ABC): @@ -25,21 +27,29 @@ class AbstractClient(ABC): def __init__( self, - api_key: str, + api_key: str = "", + ping_key: str = "", api_url: str = "https://healthchecks.io/api/", + ping_url: str = "https://hc-ping.com/", api_version: int = 1, ) -> None: """An AbstractClient that other clients can implement. Args: - api_key (str): Healthchecks.io API key + api_key (str): Healthchecks.io API key. Defaults to an empty string. + ping_key (str): Healthchecks.io Ping key. Defaults to an empty string. api_url (str): API URL. Defaults to "https://healthchecks.io/api/". + ping_url (str): Ping API url. Defaults to "https://hc-ping.com/". api_version (int): Versiopn of the api to use. Defaults to 1. """ self._api_key = api_key + self._ping_key = ping_key if not api_url.endswith("/"): api_url = f"{api_url}/" + if not ping_url.endswith("/"): + ping_url = f"{ping_url}/" self._api_url = urljoin(api_url, f"v{api_version}/") + self._ping_url = ping_url self._finalizer = finalize(self, self._finalizer_method) @abstractmethod @@ -62,6 +72,56 @@ class AbstractClient(ABC): url = urljoin(self._api_url, path) return self._add_url_params(url, params) if params is not None else url + def _get_ping_url(self, uuid: str, slug: str, endpoint: str) -> str: + """Get a url for sending a ping. + + Can take either a UUID or a Slug, but not both. + + Args: + uuid (str): uuid of a check + slug (str): slug of a check + endpoint (str): Endpoint to request + + Raises: + BadAPIRequestError: Raised if you pass a uuid and a slug, or if pinging by a slug and do not have a ping key set + + Returns: + str: url for this + """ + if uuid == "" and slug == "" or uuid != "" and slug != "": + raise BadAPIRequestError("Must pass a uuid or a slug") + + if slug != "" and self._ping_key == "": + raise BadAPIRequestError("If pinging by slug, must have a ping key set") + + if uuid != "": + return self._get_ping_url_uuid(uuid, endpoint) + return self._get_ping_url_slug(slug, endpoint) + + def _get_ping_url_uuid(self, uuid: str, endpoint: str) -> str: + """Get a ping url for a check with a uuid. + + Args: + uuid (str): uuid of a check + endpoint (str): Endpoint to request + + Returns: + str: ping url + """ + return urljoin(self._ping_url, f"{uuid}{endpoint}") + + def _get_ping_url_slug(self, slug: str, endpoint: str) -> str: + """Get a ping url for a check with a slug. + + Args: + slug (str): slug of a check + endpoint (str): Endpoint to request + + Returns: + str: ping url + """ + return urljoin(self._ping_url, f"{self._ping_key}/{slug}{endpoint}") + @property def is_closed(self) -> bool: """Is the client closed? @@ -83,6 +143,7 @@ class AbstractClient(ABC): HCAPIError: Raised when status_code is 5xx CheckNotFoundError: Raised when status_code is 404 BadAPIRequestError: Raised when status_code is 400 + HCAPIRateLimitError: Raised when status code is 429 Returns: Response: the passed in response object @@ -96,6 +157,9 @@ class AbstractClient(ABC): f"Status Code {response.status_code}. Response {response.text}" ) + if response.status_code == 429: + raise HCAPIRateLimitError(f"Rate limited on {response.request.url}") + if response.status_code == 404: raise CheckNotFoundError(f"CHeck not found at {response.request.url}") @@ -106,6 +170,54 @@ class AbstractClient(ABC): return response + @staticmethod + def check_ping_response(response: Response) -> Response: + """Checks a healthchecks.io ping response. + + Args: + response (Response): a response from the healthchecks.io api + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400 + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Response: the passed in response object + """ + if response.status_code == 401 or response.status_code == 403: + raise HCAPIAuthError("Auth failure when pinging") + + if str(response.status_code).startswith("5"): + raise HCAPIError( + f"Error when reaching out to HC API at {response.request.url}. " + f"Status Code {response.status_code}. Response {response.text}" + ) + + # ping api docs say it can return a 200 with not found for a not found check. + # in my testing, its always a 404, but this will cover what the docs say + # https://healthchecks.io/docs/http_api/ + if response.status_code == 404 or "not found" in response.text: + raise CheckNotFoundError(f"CHeck not found at {response.request.url}") + + if "rate limited" in response.text or response.status_code == 429: + raise HCAPIRateLimitError(f"Rate limited on {response.request.url}") + + if response.status_code == 400: + raise BadAPIRequestError( + f"Bad request when requesting {response.request.url}. {response.text}" + ) + + if response.status_code == 409: + raise NonUniqueSlugError( + f"Bad request, slug conflict {response.request.url}. {response.text}" + ) + + return response + @staticmethod def _add_url_params( url: str, params: Dict[str, Union[str, int, bool]], replace: bool = True diff --git a/src/healthchecks_io/client/async_client.py b/src/healthchecks_io/client/async_client.py index f5c3325..2383cd0 100644 --- a/src/healthchecks_io/client/async_client.py +++ b/src/healthchecks_io/client/async_client.py @@ -3,6 +3,7 @@ import asyncio from typing import Dict from typing import List from typing import Optional +from typing import Tuple from httpx import AsyncClient as HTTPXAsyncClient @@ -18,16 +19,20 @@ class AsyncClient(AbstractClient): def __init__( self, - api_key: str, + api_key: str = "", + ping_key: str = "", api_url: str = "https://healthchecks.io/api/", + ping_url: str = "https://hc-ping.com/", api_version: int = 1, client: Optional[HTTPXAsyncClient] = None, ) -> None: """An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api. Args: - api_key (str): Healthchecks.io API key + api_key (str): Healthchecks.io API key. Defaults to an empty string. + ping_key (str): Healthchecks.io Ping key. Defaults to an empty string. api_url (str): API URL. Defaults to "https://healthchecks.io/api/". + ping_url (str): Ping API url. Defaults to "https://hc-ping.com/" api_version (int): Versiopn of the api to use. Defaults to 1. client (Optional[HTTPXAsyncClient], optional): A httpx.Asyncclient. If not passed in, one will be created for this object. Defaults to None. @@ -35,7 +40,13 @@ class AsyncClient(AbstractClient): self._client: HTTPXAsyncClient = ( HTTPXAsyncClient() if client is None else client ) - super().__init__(api_key=api_key, api_url=api_url, api_version=api_version) + super().__init__( + api_key=api_key, + ping_key=ping_key, + api_url=api_url, + ping_url=ping_url, + api_version=api_version, + ) self._client.headers["X-Api-Key"] = self._api_key self._client.headers["user-agent"] = f"py-healthchecks.io-async/{VERSION}" self._client.headers["Content-type"] = "application/json" @@ -58,6 +69,8 @@ class AsyncClient(AbstractClient): Raises: HCAPIAuthError: When the API returns a 401, indicates an api key issue HCAPIError: When the API returns anything other than a 200 or 401 + HCAPIRateLimitError: Raised when status code is 429 + Returns: List[checks.Check]: [description] @@ -92,6 +105,8 @@ class AsyncClient(AbstractClient): HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx CheckNotFoundError: Raised when status_code is 404 + HCAPIRateLimitError: Raised when status code is 429 + """ request_url = self._get_api_request_url(f"checks/{check_id}") @@ -137,6 +152,7 @@ class AsyncClient(AbstractClient): HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx CheckNotFoundError: Raised when status_code is 404 + HCAPIRateLimitError: Raised when status code is 429 """ request_url = self._get_api_request_url(f"checks/{check_id}") @@ -160,6 +176,8 @@ class AsyncClient(AbstractClient): HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx CheckNotFoundError: Raised when status_code is 404 + HCAPIRateLimitError: Raised when status code is 429 + """ request_url = self._get_api_request_url(f"checks/{check_id}/pings/") @@ -185,6 +203,8 @@ class AsyncClient(AbstractClient): HCAPIError: Raised when status_code is 5xx CheckNotFoundError: Raised when status_code is 404 BadAPIRequestError: Raised when status_code is 400 + HCAPIRateLimitError: Raised when status code is 429 + Args: check_id (str): check uuid @@ -214,6 +234,7 @@ class AsyncClient(AbstractClient): Raises: HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx + HCAPIRateLimitError: Raised when status code is 429 Returns: List[Optional[integrations.Integration]]: List of integrations for the project @@ -244,14 +265,151 @@ class AsyncClient(AbstractClient): Raises: HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx + HCAPIRateLimitError: Raised when status code is 429 Returns: Dict[str, badges.Badges]: Dictionary of all tags in the project with badges """ request_url = self._get_api_request_url("badges/") - url = await self._client.get(request_url) - response = self.check_response(url) + response = self.check_response(await self._client.get(request_url)) return { key: badges.Badges.from_api_result(item) for key, item in response.json()["badges"].items() } + + async def success_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Signals to Healthchecks.io that a job has completed successfully. + + Can also be used to indicate a continuously running process is still running and healthy. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "") + response = self.check_ping_response(await self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + async def start_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Sends a "job has started!" message to Healthchecks.io. + + Sending a "start" signal is optional, but it enables a few extra features: + * Healthchecks.io will measure and display job execution times + * Healthchecks.io will detect if the job runs longer than its configured grace time + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "/start") + response = self.check_ping_response(await self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + async def fail_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Signals to Healthchecks.io that the job has failed. + + Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "/fail") + response = self.check_ping_response(await self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + async def exit_code_ping( + self, exit_code: int, uuid: str = "", slug: str = "" + ) -> Tuple[bool, str]: + """Signals to Healthchecks.io that the job has failed. + + Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + exit_code (int): Exit code to sent, int from 0 to 255 + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, f"/{exit_code}") + response = self.check_ping_response(await self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) diff --git a/src/healthchecks_io/client/exceptions.py b/src/healthchecks_io/client/exceptions.py index abc8d1d..c21b09a 100644 --- a/src/healthchecks_io/client/exceptions.py +++ b/src/healthchecks_io/client/exceptions.py @@ -23,3 +23,15 @@ class BadAPIRequestError(HCAPIError): """Thrown when an api request returns a 400.""" ... + + +class HCAPIRateLimitError(HCAPIError): + """Thrown when the api returns a rate limit response.""" + + ... + + +class NonUniqueSlugError(HCAPIError): + """Thrown when the api returns a 409 when pinging.""" + + ... diff --git a/src/healthchecks_io/client/sync_client.py b/src/healthchecks_io/client/sync_client.py index ae0b5dd..6594c8d 100644 --- a/src/healthchecks_io/client/sync_client.py +++ b/src/healthchecks_io/client/sync_client.py @@ -2,6 +2,7 @@ from typing import Dict from typing import List from typing import Optional +from typing import Tuple from httpx import Client as HTTPXClient @@ -17,22 +18,32 @@ class Client(AbstractClient): def __init__( self, - api_key: str, + api_key: str = "", + ping_key: str = "", api_url: str = "https://healthchecks.io/api/", + ping_url: str = "https://hc-ping.com/", api_version: int = 1, client: Optional[HTTPXClient] = None, ) -> None: """An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api. Args: - api_key (str): Healthchecks.io API key + api_key (str): Healthchecks.io API key. Defaults to an empty string. + ping_key (str): Healthchecks.io Ping key. Defaults to an empty string. api_url (str): API URL. Defaults to "https://healthchecks.io/api/". + ping_url (str): Ping API url. Defaults to "https://hc-ping.com/" api_version (int): Versiopn of the api to use. Defaults to 1. client (Optional[HTTPXClient], optional): A httpx.Client. If not passed in, one will be created for this object. Defaults to None. """ self._client: HTTPXClient = HTTPXClient() if client is None else client - super().__init__(api_key=api_key, api_url=api_url, api_version=api_version) + super().__init__( + api_key=api_key, + ping_key=ping_key, + api_url=api_url, + ping_url=ping_url, + api_version=api_version, + ) self._client.headers["X-Api-Key"] = self._api_key self._client.headers["user-agent"] = f"py-healthchecks.io/{VERSION}" self._client.headers["Content-type"] = "application/json" @@ -247,3 +258,140 @@ class Client(AbstractClient): key: badges.Badges.from_api_result(item) for key, item in response.json()["badges"].items() } + + def success_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Signals to Healthchecks.io that a job has completed successfully. + + Can also be used to indicate a continuously running process is still running and healthy. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "") + response = self.check_ping_response(self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + def start_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Sends a "job has started!" message to Healthchecks.io. + + Sending a "start" signal is optional, but it enables a few extra features: + * Healthchecks.io will measure and display job execution times + * Healthchecks.io will detect if the job runs longer than its configured grace time + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "/start") + response = self.check_ping_response(self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + def fail_ping(self, uuid: str = "", slug: str = "") -> Tuple[bool, str]: + """Signals to Healthchecks.io that the job has failed. + + Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, "/fail") + response = self.check_ping_response(self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) + + def exit_code_ping( + self, exit_code: int, uuid: str = "", slug: str = "" + ) -> Tuple[bool, str]: + """Signals to Healthchecks.io that the job has failed. + + Actively signaling a failure minimizes the delay from your monitored service failing to you receiving an alert. + + Can take a uuid or a slug. If you call with a slug, you much have a + ping key set. + + Check's slug is not guaranteed to be unique. If multiple checks in the + project have the same name, they also have the same slug. If you make + a Pinging API request using a non-unique slug, Healthchecks.io will + return the "409 Conflict" HTTP status code and ignore the request. + + Args: + exit_code (int): Exit code to sent, int from 0 to 255 + uuid (str): Check's UUID. Defaults to "". + slug (str): Check's Slug. Defaults to "". + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 or response text has "not found" in it + BadAPIRequestError: Raised when status_code is 400, or if you pass a uuid and a slug, or if + pinging by a slug and do not have a ping key set + HCAPIRateLimitError: Raised when status code is 429 or response text has "rate limited" in it + NonUniqueSlugError: Raused when status code is 409. + + Returns: + Tuple[bool, str]: success (true or false) and the response text + """ + ping_url = self._get_ping_url(uuid, slug, f"/{exit_code}") + response = self.check_ping_response(self._client.get(ping_url)) + return (True if response.status_code == 200 else False, response.text) diff --git a/tests/client/test_abstract.py b/tests/client/test_abstract.py index 7ba2318..c444f5d 100644 --- a/tests/client/test_abstract.py +++ b/tests/client/test_abstract.py @@ -1,10 +1,86 @@ +from copy import deepcopy + +import pytest +from httpx import Request +from httpx import Response + +from healthchecks_io import BadAPIRequestError +from healthchecks_io import CheckNotFoundError +from healthchecks_io import HCAPIAuthError +from healthchecks_io import HCAPIError +from healthchecks_io import HCAPIRateLimitError +from healthchecks_io import NonUniqueSlugError from healthchecks_io.client._abstract import AbstractClient -def test_abstract_add_url_params(): - AbstractClient.__abstractmethods__ = set() - abstract_client = AbstractClient("test") - url = abstract_client._add_url_params( +def test_abstract_add_url_params(test_abstract_client): + + url = test_abstract_client._add_url_params( "http://test.com/?test=test", {"test": "test2"} ) assert url == "http://test.com/?test=test2" + + +def test_get_ping_url(test_abstract_client): + url = test_abstract_client._get_ping_url("test", "", "/endpoint") + assert url == f"{test_abstract_client._ping_url}test/endpoint" + + # test for raising when we send both a slug and a uuid + with pytest.raises(BadAPIRequestError): + test_abstract_client._get_ping_url("uuid", "slug", "endpoint") + + # test for raising when we try a slug w/o a ping_key + test_abstract_client._ping_key = "" + with pytest.raises(BadAPIRequestError): + test_abstract_client._get_ping_url("", "slug", "endpoint") + + +check_response_parameters = [ + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=401, request=Request("get", "http://test")), + HCAPIAuthError, + ), + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=500, request=Request("get", "http://test")), + HCAPIError, + ), + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=429, request=Request("get", "http://test")), + HCAPIRateLimitError, + ), + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=404, request=Request("get", "http://test")), + CheckNotFoundError, + ), + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=400, request=Request("get", "http://test")), + BadAPIRequestError, + ), +] + + +@pytest.mark.parametrize("test_client, response, exception", check_response_parameters) +def test_check_resposne(test_client, response, exception): + with pytest.raises(exception): + test_client.check_response(response) + + +ping_response_parameters = deepcopy(check_response_parameters) +ping_response_parameters.append( + ( + pytest.lazy_fixture("test_abstract_client"), + Response(status_code=409, request=Request("get", "http://test")), + NonUniqueSlugError, + ) +) + + +@pytest.mark.parametrize("test_client, response, exception", ping_response_parameters) +def test_check_ping_resposne(test_client, response, exception): + with pytest.raises(exception): + test_client.check_ping_response(response) diff --git a/tests/client/test_async.py b/tests/client/test_async.py index fec6408..1d68e7b 100644 --- a/tests/client/test_async.py +++ b/tests/client/test_async.py @@ -222,3 +222,79 @@ async def test_aget_badges(fake_badges_api_result, respx_mock, test_async_client ) integrations = await test_async_client.get_badges() assert integrations.keys() == fake_badges_api_result["badges"].keys() + + +ping_test_parameters = [ + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "test", + "success_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "1234/test", + "success_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "test/start", + "start_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "1234/test/start", + "start_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "test/fail", + "fail_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "1234/test/fail", + "fail_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "test/0", + "exit_code_ping", + {"exit_code": 0, "uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_async_client"), + "1234/test/0", + "exit_code_ping", + {"exit_code": 0, "slug": "test"}, + ), +] + + +@pytest.mark.asyncio +@pytest.mark.respx +@pytest.mark.parametrize( + "respx_mocker, tc, url, ping_method, method_kwargs", ping_test_parameters +) +async def test_success_ping(respx_mocker, tc, url, ping_method, method_kwargs): + channels_url = urljoin(tc._ping_url, url) + respx_mocker.get(channels_url).mock( + return_value=Response(status_code=200, text="OK") + ) + ping_method = getattr(tc, ping_method) + result = await ping_method(**method_kwargs) + assert result[0] is True + assert result[1] == "OK" diff --git a/tests/client/test_sync.py b/tests/client/test_sync.py index 55fb863..b4bf877 100644 --- a/tests/client/test_sync.py +++ b/tests/client/test_sync.py @@ -195,3 +195,78 @@ def test_get_badges(fake_badges_api_result, respx_mock, test_client): ) integrations = test_client.get_badges() assert integrations.keys() == fake_badges_api_result["badges"].keys() + + +ping_test_parameters = [ + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "test", + "success_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "1234/test", + "success_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "test/start", + "start_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "1234/test/start", + "start_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "test/fail", + "fail_ping", + {"uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "1234/test/fail", + "fail_ping", + {"slug": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "test/0", + "exit_code_ping", + {"exit_code": 0, "uuid": "test"}, + ), + ( + pytest.lazy_fixture("respx_mock"), + pytest.lazy_fixture("test_client"), + "1234/test/0", + "exit_code_ping", + {"exit_code": 0, "slug": "test"}, + ), +] + + +@pytest.mark.respx +@pytest.mark.parametrize( + "respx_mocker, tc, url, ping_method, method_kwargs", ping_test_parameters +) +def test_success_ping(respx_mocker, tc, url, ping_method, method_kwargs): + channels_url = urljoin(tc._ping_url, url) + respx_mocker.get(channels_url).mock( + return_value=Response(status_code=200, text="OK") + ) + ping_method = getattr(tc, ping_method) + result, text = ping_method(**method_kwargs) + assert result is True + assert text == "OK" diff --git a/tests/conftest.py b/tests/conftest.py index b6ac9c0..c217244 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import pytest from healthchecks_io import AsyncClient from healthchecks_io import Client +from healthchecks_io.client._abstract import AbstractClient from healthchecks_io.schemas import checks @@ -82,16 +83,30 @@ def fake_ro_check(fake_check: checks.Check): yield fake_check +client_kwargs = { + "api_key": "test", + "api_url": "https://localhost/api", + "ping_url": "https://localhost/ping", + "ping_key": "1234", +} + + @pytest.fixture def test_async_client(): """An AsyncClient for testing, set to a nonsense url so we aren't pinging healtchecks.""" - yield AsyncClient(api_key="test", api_url="https://localhost/api") + yield AsyncClient(**client_kwargs) @pytest.fixture def test_client(): """A Client for testing, set to a nonsense url so we aren't pinging healtchecks.""" - yield Client(api_key="test", api_url="https://localhost/api") + yield Client(**client_kwargs) + + +@pytest.fixture +def test_abstract_client(): + AbstractClient.__abstractmethods__ = set() + yield AbstractClient(**client_kwargs) @pytest.fixture