ping api and tests

This commit is contained in:
Andrew Herrington
2021-12-11 23:38:03 -06:00
parent 7f1ca2d331
commit fb50209a5f
12 changed files with 719 additions and 18 deletions

View File

@@ -35,6 +35,14 @@ nox.options.sessions = (
"docs-build", "docs-build",
) )
mypy_type_packages = ("types-croniter", "types-pytz") 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: def activate_virtualenv_in_precommit_hooks(session: Session) -> None:
@@ -158,7 +166,7 @@ def bandit(session: Session) -> None:
def tests(session: Session) -> None: def tests(session: Session) -> None:
"""Run the test suite.""" """Run the test suite."""
session.install(".") session.install(".")
session.install("coverage[toml]", "pytest", "pygments", "respx", "pytest-asyncio") session.install(*test_requirements)
try: try:
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs) session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
finally: finally:

17
poetry.lock generated
View File

@@ -788,6 +788,17 @@ pytest = ">=4.6"
[package.extras] [package.extras]
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 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]] [[package]]
name = "pytest-mock" name = "pytest-mock"
version = "3.6.1" version = "3.6.1"
@@ -1269,7 +1280,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "92a37835a7713cb65e9fbe3c3500ec600c4c869b6fdcb6c0fc9c35702a01eeb2" content-hash = "f6a7c25e14e47b3f1ff69a6ab9a3bd23041d8d00f4df1c531c2ed539cac62bb9"
[metadata.files] [metadata.files]
alabaster = [ alabaster = [
@@ -1687,6 +1698,10 @@ pytest-cov = [
{file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
{file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, {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 = [ pytest-mock = [
{file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"},
{file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"},

View File

@@ -54,6 +54,7 @@ pytest_async = "^0.1.1"
pytest-asyncio = "^0.16.0" pytest-asyncio = "^0.16.0"
respx = "^0.19.0" respx = "^0.19.0"
pytest-mock = "^3.6.1" pytest-mock = "^3.6.1"
pytest-lazy-fixture = "^0.6.3"
[tool.coverage.paths] [tool.coverage.paths]
source = ["src", "*/site-packages"] source = ["src", "*/site-packages"]

View File

@@ -7,6 +7,8 @@ from .client.exceptions import BadAPIRequestError # noqa: F401, E402
from .client.exceptions import CheckNotFoundError # noqa: F401, E402 from .client.exceptions import CheckNotFoundError # noqa: F401, E402
from .client.exceptions import HCAPIAuthError # noqa: F401, E402 from .client.exceptions import HCAPIAuthError # noqa: F401, E402
from .client.exceptions import HCAPIError # 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__ = [ __all__ = [
"AsyncClient", "AsyncClient",
@@ -15,4 +17,7 @@ __all__ = [
"CheckNotFoundError", "CheckNotFoundError",
"HCAPIAuthError", "HCAPIAuthError",
"HCAPIError", "HCAPIError",
"CheckNotFoundError",
"HCAPIRateLimitError",
"NonUniqueSlugError",
] ]

View File

@@ -18,6 +18,8 @@ from .exceptions import BadAPIRequestError
from .exceptions import CheckNotFoundError from .exceptions import CheckNotFoundError
from .exceptions import HCAPIAuthError from .exceptions import HCAPIAuthError
from .exceptions import HCAPIError from .exceptions import HCAPIError
from .exceptions import HCAPIRateLimitError
from .exceptions import NonUniqueSlugError
class AbstractClient(ABC): class AbstractClient(ABC):
@@ -25,21 +27,29 @@ class AbstractClient(ABC):
def __init__( def __init__(
self, self,
api_key: str, api_key: str = "",
ping_key: str = "",
api_url: str = "https://healthchecks.io/api/", api_url: str = "https://healthchecks.io/api/",
ping_url: str = "https://hc-ping.com/",
api_version: int = 1, api_version: int = 1,
) -> None: ) -> None:
"""An AbstractClient that other clients can implement. """An AbstractClient that other clients can implement.
Args: 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/". 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. api_version (int): Versiopn of the api to use. Defaults to 1.
""" """
self._api_key = api_key self._api_key = api_key
self._ping_key = ping_key
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url = f"{api_url}/" 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._api_url = urljoin(api_url, f"v{api_version}/")
self._ping_url = ping_url
self._finalizer = finalize(self, self._finalizer_method) self._finalizer = finalize(self, self._finalizer_method)
@abstractmethod @abstractmethod
@@ -62,6 +72,56 @@ class AbstractClient(ABC):
url = urljoin(self._api_url, path) url = urljoin(self._api_url, path)
return self._add_url_params(url, params) if params is not None else url 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 @property
def is_closed(self) -> bool: def is_closed(self) -> bool:
"""Is the client closed? """Is the client closed?
@@ -83,6 +143,7 @@ class AbstractClient(ABC):
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404 CheckNotFoundError: Raised when status_code is 404
BadAPIRequestError: Raised when status_code is 400 BadAPIRequestError: Raised when status_code is 400
HCAPIRateLimitError: Raised when status code is 429
Returns: Returns:
Response: the passed in response object Response: the passed in response object
@@ -96,6 +157,9 @@ class AbstractClient(ABC):
f"Status Code {response.status_code}. Response {response.text}" 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: if response.status_code == 404:
raise CheckNotFoundError(f"CHeck not found at {response.request.url}") raise CheckNotFoundError(f"CHeck not found at {response.request.url}")
@@ -106,6 +170,54 @@ class AbstractClient(ABC):
return response 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 @staticmethod
def _add_url_params( def _add_url_params(
url: str, params: Dict[str, Union[str, int, bool]], replace: bool = True url: str, params: Dict[str, Union[str, int, bool]], replace: bool = True

View File

@@ -3,6 +3,7 @@ import asyncio
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple
from httpx import AsyncClient as HTTPXAsyncClient from httpx import AsyncClient as HTTPXAsyncClient
@@ -18,16 +19,20 @@ class AsyncClient(AbstractClient):
def __init__( def __init__(
self, self,
api_key: str, api_key: str = "",
ping_key: str = "",
api_url: str = "https://healthchecks.io/api/", api_url: str = "https://healthchecks.io/api/",
ping_url: str = "https://hc-ping.com/",
api_version: int = 1, api_version: int = 1,
client: Optional[HTTPXAsyncClient] = None, client: Optional[HTTPXAsyncClient] = None,
) -> None: ) -> None:
"""An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api. """An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api.
Args: 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/". 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. api_version (int): Versiopn of the api to use. Defaults to 1.
client (Optional[HTTPXAsyncClient], optional): A httpx.Asyncclient. If not client (Optional[HTTPXAsyncClient], optional): A httpx.Asyncclient. If not
passed in, one will be created for this object. Defaults to None. passed in, one will be created for this object. Defaults to None.
@@ -35,7 +40,13 @@ class AsyncClient(AbstractClient):
self._client: HTTPXAsyncClient = ( self._client: HTTPXAsyncClient = (
HTTPXAsyncClient() if client is None else client 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["X-Api-Key"] = self._api_key
self._client.headers["user-agent"] = f"py-healthchecks.io-async/{VERSION}" self._client.headers["user-agent"] = f"py-healthchecks.io-async/{VERSION}"
self._client.headers["Content-type"] = "application/json" self._client.headers["Content-type"] = "application/json"
@@ -58,6 +69,8 @@ class AsyncClient(AbstractClient):
Raises: Raises:
HCAPIAuthError: When the API returns a 401, indicates an api key issue HCAPIAuthError: When the API returns a 401, indicates an api key issue
HCAPIError: When the API returns anything other than a 200 or 401 HCAPIError: When the API returns anything other than a 200 or 401
HCAPIRateLimitError: Raised when status code is 429
Returns: Returns:
List[checks.Check]: [description] List[checks.Check]: [description]
@@ -92,6 +105,8 @@ class AsyncClient(AbstractClient):
HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404 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}") 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 HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404 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}") 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 HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404 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/") 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 HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404 CheckNotFoundError: Raised when status_code is 404
BadAPIRequestError: Raised when status_code is 400 BadAPIRequestError: Raised when status_code is 400
HCAPIRateLimitError: Raised when status code is 429
Args: Args:
check_id (str): check uuid check_id (str): check uuid
@@ -214,6 +234,7 @@ class AsyncClient(AbstractClient):
Raises: Raises:
HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
HCAPIRateLimitError: Raised when status code is 429
Returns: Returns:
List[Optional[integrations.Integration]]: List of integrations for the project List[Optional[integrations.Integration]]: List of integrations for the project
@@ -244,14 +265,151 @@ class AsyncClient(AbstractClient):
Raises: Raises:
HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx HCAPIError: Raised when status_code is 5xx
HCAPIRateLimitError: Raised when status code is 429
Returns: Returns:
Dict[str, badges.Badges]: Dictionary of all tags in the project with badges Dict[str, badges.Badges]: Dictionary of all tags in the project with badges
""" """
request_url = self._get_api_request_url("badges/") request_url = self._get_api_request_url("badges/")
url = await self._client.get(request_url) response = self.check_response(await self._client.get(request_url))
response = self.check_response(url)
return { return {
key: badges.Badges.from_api_result(item) key: badges.Badges.from_api_result(item)
for key, item in response.json()["badges"].items() 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)

View File

@@ -23,3 +23,15 @@ class BadAPIRequestError(HCAPIError):
"""Thrown when an api request returns a 400.""" """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."""
...

View File

@@ -2,6 +2,7 @@
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple
from httpx import Client as HTTPXClient from httpx import Client as HTTPXClient
@@ -17,22 +18,32 @@ class Client(AbstractClient):
def __init__( def __init__(
self, self,
api_key: str, api_key: str = "",
ping_key: str = "",
api_url: str = "https://healthchecks.io/api/", api_url: str = "https://healthchecks.io/api/",
ping_url: str = "https://hc-ping.com/",
api_version: int = 1, api_version: int = 1,
client: Optional[HTTPXClient] = None, client: Optional[HTTPXClient] = None,
) -> None: ) -> None:
"""An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api. """An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api.
Args: 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/". 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. api_version (int): Versiopn of the api to use. Defaults to 1.
client (Optional[HTTPXClient], optional): A httpx.Client. If not client (Optional[HTTPXClient], optional): A httpx.Client. If not
passed in, one will be created for this object. Defaults to None. passed in, one will be created for this object. Defaults to None.
""" """
self._client: HTTPXClient = HTTPXClient() if client is None else client 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["X-Api-Key"] = self._api_key
self._client.headers["user-agent"] = f"py-healthchecks.io/{VERSION}" self._client.headers["user-agent"] = f"py-healthchecks.io/{VERSION}"
self._client.headers["Content-type"] = "application/json" self._client.headers["Content-type"] = "application/json"
@@ -247,3 +258,140 @@ class Client(AbstractClient):
key: badges.Badges.from_api_result(item) key: badges.Badges.from_api_result(item)
for key, item in response.json()["badges"].items() 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)

View File

@@ -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 from healthchecks_io.client._abstract import AbstractClient
def test_abstract_add_url_params(): def test_abstract_add_url_params(test_abstract_client):
AbstractClient.__abstractmethods__ = set()
abstract_client = AbstractClient("test") url = test_abstract_client._add_url_params(
url = abstract_client._add_url_params(
"http://test.com/?test=test", {"test": "test2"} "http://test.com/?test=test", {"test": "test2"}
) )
assert url == "http://test.com/?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)

View File

@@ -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() integrations = await test_async_client.get_badges()
assert integrations.keys() == fake_badges_api_result["badges"].keys() 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"

View File

@@ -195,3 +195,78 @@ def test_get_badges(fake_badges_api_result, respx_mock, test_client):
) )
integrations = test_client.get_badges() integrations = test_client.get_badges()
assert integrations.keys() == fake_badges_api_result["badges"].keys() 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"

View File

@@ -6,6 +6,7 @@ import pytest
from healthchecks_io import AsyncClient from healthchecks_io import AsyncClient
from healthchecks_io import Client from healthchecks_io import Client
from healthchecks_io.client._abstract import AbstractClient
from healthchecks_io.schemas import checks from healthchecks_io.schemas import checks
@@ -82,16 +83,30 @@ def fake_ro_check(fake_check: checks.Check):
yield fake_check yield fake_check
client_kwargs = {
"api_key": "test",
"api_url": "https://localhost/api",
"ping_url": "https://localhost/ping",
"ping_key": "1234",
}
@pytest.fixture @pytest.fixture
def test_async_client(): def test_async_client():
"""An AsyncClient for testing, set to a nonsense url so we aren't pinging healtchecks.""" """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 @pytest.fixture
def test_client(): def test_client():
"""A Client for testing, set to a nonsense url so we aren't pinging healtchecks.""" """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 @pytest.fixture