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

@@ -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",
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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."""
...

View File

@@ -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)