mirror of
https://github.com/andrewthetechie/py-healthchecks.io.git
synced 2025-12-06 17:48:30 +01:00
ping api and tests
This commit is contained in:
10
noxfile.py
10
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:
|
||||
|
||||
17
poetry.lock
generated
17
poetry.lock
generated
@@ -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"},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user