Skip to content

Commit 6f1802a

Browse files
keyvankhademiKeyvan
andauthored
Add health check api (#24)
* add timestamp to prices and keep last updated at in publisher * Add .idea/ to .gitignore * add a simple health check api * add port and health check logic * run black * empty * run pre-commit * undo remove blank line * fix: update the last successful update only when it's greater * refactor: fix typings in pyth replicator * fix: type error in coin_gecko.py * refactor: health check code to improve readability and performance * fix: logic error updating last successful update * chore: update version to 1.1.0 in pyproject.toml * refactor: rename port to health_check_port * refactor: rename health_check_test_period_secs to health_check_threshold_secs * refactor: use fastapi for health check api * chore: format imports * Refactor health check API and add is_healthy method to Publisher class --------- Co-authored-by: Keyvan <keyvan@dourolabs.xyz>
1 parent 2fbd4b6 commit 6f1802a

File tree

11 files changed

+671
-96
lines changed

11 files changed

+671
-96
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,6 @@ dmypy.json
131131
# Visual Studio Code
132132
.vscode/
133133
.devcontainer/
134+
135+
# PyCharm
136+
.idea/

config/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
provider_engine = 'pyth_replicator'
66

77
product_update_interval_secs = 10
8+
health_check_port = 8000
9+
10+
# The health check will return a failure status if no price data has been published within the specified time frame.
11+
health_check_threshold_secs = 60
812

913
[publisher.pythd]
1014
endpoint = 'ws://127.0.0.1:8910'

example_publisher/__main__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import asyncio
22
import os
33
import sys
4+
import threading
5+
import uvicorn
46
from example_publisher.config import Config
57
from example_publisher.publisher import Publisher
68
import typed_settings as ts
79
import click
810
import logging
911
import structlog
12+
from example_publisher.api.health_check import app, API
1013

1114
_DEFAULT_CONFIG_PATH = os.path.join("config", "config.toml")
1215

@@ -26,13 +29,20 @@
2629
)
2730
def main(config_path):
2831

29-
config = ts.load(
32+
config: Config = ts.load(
3033
cls=Config,
3134
appname="publisher",
3235
config_files=[config_path],
3336
)
3437

3538
publisher = Publisher(config=config)
39+
API.publisher = publisher
40+
41+
def run_server():
42+
uvicorn.run(app, host="0.0.0.0", port=config.health_check_port)
43+
44+
server_thread = threading.Thread(target=run_server)
45+
server_thread.start()
3646

3747
async def run():
3848
try:

example_publisher/api/health_check.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from fastapi import FastAPI, status
2+
from fastapi.responses import JSONResponse
3+
from example_publisher.publisher import Publisher
4+
5+
6+
class API(FastAPI):
7+
publisher: Publisher
8+
9+
10+
app = API()
11+
12+
13+
@app.get("/health")
14+
def health_check():
15+
healthy = API.publisher.is_healthy()
16+
last_successful_update = API.publisher.last_successful_update
17+
if not healthy:
18+
return JSONResponse(
19+
content={
20+
"status": "error",
21+
"last_successful_update": last_successful_update,
22+
},
23+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
24+
)
25+
return JSONResponse(
26+
content={
27+
"status": "ok",
28+
"last_successful_update": last_successful_update,
29+
},
30+
status_code=status.HTTP_200_OK,
31+
)

example_publisher/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class PythReplicatorConfig:
4747
class Config:
4848
provider_engine: str
4949
pythd: Pythd
50+
health_check_port: int
51+
health_check_threshold_secs: int
5052
product_update_interval_secs: int = ts.option(default=60)
5153
coin_gecko: Optional[CoinGeckoConfig] = ts.option(default=None)
5254
pyth_replicator: Optional[PythReplicatorConfig] = ts.option(default=None)

example_publisher/provider.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
from dataclasses import dataclass
44
from typing import List, Optional
55

6+
67
Symbol = str
8+
UnixTimestamp = int
79

810

911
@dataclass
1012
class Price:
1113
price: float
1214
conf: float
15+
timestamp: UnixTimestamp
1316

1417

1518
class Provider(ABC):

example_publisher/providers/coin_gecko.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import asyncio
2+
from math import floor
3+
import time
24
from typing import Dict, List, Optional
35
from pycoingecko import CoinGeckoAPI
46
from structlog import get_logger
@@ -16,7 +18,7 @@
1618
class CoinGecko(Provider):
1719
def __init__(self, config: CoinGeckoConfig) -> None:
1820
self._api: CoinGeckoAPI = CoinGeckoAPI()
19-
self._prices: Dict[Id, float] = {}
21+
self._prices: Dict[Id, Price] = {}
2022
self._symbol_to_id: Dict[Symbol, Id] = {
2123
product.symbol: product.coin_gecko_id for product in config.products
2224
}
@@ -45,18 +47,19 @@ def _update_prices(self) -> None:
4547
ids=list(self._prices.keys()), vs_currencies=USD, precision=18
4648
)
4749
for id_, prices in result.items():
48-
self._prices[id_] = prices[USD]
50+
price = prices[USD]
51+
self._prices[id_] = Price(
52+
price,
53+
price * self._config.confidence_ratio_bps / 10000,
54+
floor(time.time()),
55+
)
4956
log.info("updated prices from CoinGecko", prices=self._prices)
5057

51-
def _get_price(self, id: Id) -> float:
58+
def _get_price(self, id: Id) -> Optional[Price]:
5259
return self._prices.get(id, None)
5360

5461
def latest_price(self, symbol: Symbol) -> Optional[Price]:
5562
id = self._symbol_to_id.get(symbol)
56-
if not id:
63+
if id is None:
5764
return None
58-
59-
price = self._get_price(id)
60-
if not price:
61-
return None
62-
return Price(price, price * self._config.confidence_ratio_bps / 10000)
65+
return self._get_price(id)

example_publisher/providers/pyth_replicator.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
log = get_logger()
1515

16-
UnixTimestamp = int
1716

1817
# Any feed with >= this number of min publishers is considered "coming soon".
1918
COMING_SOON_MIN_PUB_THRESHOLD = 10
@@ -28,9 +27,7 @@ def __init__(self, config: PythReplicatorConfig) -> None:
2827
first_mapping_account_key=config.first_mapping,
2928
program_key=config.program_key,
3029
)
31-
self._prices: Dict[
32-
str, Tuple[float | None, float | None, UnixTimestamp | None]
33-
] = {}
30+
self._prices: Dict[str, Optional[Price]] = {}
3431
self._update_accounts_task: asyncio.Task | None = None
3532

3633
async def _update_loop(self) -> None:
@@ -47,20 +44,25 @@ async def _update_loop(self) -> None:
4744
while True:
4845
update = await self._ws.next_update()
4946
log.debug("Received a WS update", account_key=update.key, slot=update.slot)
50-
if isinstance(update, PythPriceAccount):
47+
if isinstance(update, PythPriceAccount) and update.product is not None:
5148
symbol = update.product.symbol
5249

5350
if self._prices.get(symbol) is None:
54-
self._prices[symbol] = [None, None, None]
51+
self._prices[symbol] = None
5552

56-
if update.aggregate_price_status == PythPriceStatus.TRADING:
57-
self._prices[symbol] = [
53+
if (
54+
update.aggregate_price_status == PythPriceStatus.TRADING
55+
and update.aggregate_price is not None
56+
and update.aggregate_price_confidence_interval is not None
57+
):
58+
self._prices[symbol] = Price(
5859
update.aggregate_price,
5960
update.aggregate_price_confidence_interval,
6061
update.timestamp,
61-
]
62+
)
6263
elif (
6364
self._config.manual_agg_enabled
65+
and update.min_publishers is not None
6466
and update.min_publishers >= COMING_SOON_MIN_PUB_THRESHOLD
6567
):
6668
# Do the manual aggregation based on the recent active publishers
@@ -71,13 +73,14 @@ async def _update_loop(self) -> None:
7173
# Note that we only manually aggregate for feeds that are coming soon. Some feeds should go
7274
# offline outside of market hours (e.g., Equities, Metals). Manually aggregating for these feeds
7375
# can cause them to come online at unexpected times if a single data provider publishes at that time.
74-
prices = []
76+
prices: List[float] = []
7577

7678
current_slot = update.slot
7779
for price_component in update.price_components:
7880
price = price_component.latest_price_info
7981
if (
8082
price.price_status == PythPriceStatus.TRADING
83+
and current_slot is not None
8184
and current_slot - price.pub_slot
8285
<= self._config.manual_agg_max_slot_diff
8386
):
@@ -93,11 +96,11 @@ async def _update_loop(self) -> None:
9396
if prices:
9497
agg_price, agg_confidence_interval = manual_aggregate(prices)
9598

96-
self._prices[symbol] = [
99+
self._prices[symbol] = Price(
97100
agg_price,
98101
agg_confidence_interval,
99102
update.timestamp,
100-
]
103+
)
101104

102105
log.info(
103106
"Received a price update", symbol=symbol, price=self._prices[symbol]
@@ -115,7 +118,7 @@ async def _update_accounts_loop(self) -> None:
115118

116119
await asyncio.sleep(self._config.account_update_interval_secs)
117120

118-
def upd_products(self, _: List[Symbol]) -> None:
121+
def upd_products(self, *args) -> None:
119122
# This provider stores all the possible feeds and
120123
# does not care about the desired products as knowing
121124
# them does not improve the performance of the replicator
@@ -124,15 +127,15 @@ def upd_products(self, _: List[Symbol]) -> None:
124127
pass
125128

126129
def latest_price(self, symbol: Symbol) -> Optional[Price]:
127-
price, conf, timestamp = self._prices.get(symbol, [None, None, None])
130+
price = self._prices.get(symbol, None)
128131

129-
if not price or not conf or not timestamp:
132+
if not price:
130133
return None
131134

132-
if time.time() - timestamp > self._config.staleness_time_in_secs:
135+
if time.time() - price.timestamp > self._config.staleness_time_in_secs:
133136
return None
134137

135-
return Price(price, conf)
138+
return price
136139

137140

138141
def manual_aggregate(prices: List[float]) -> Tuple[float, float]:

example_publisher/publisher.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import asyncio
2+
import time
23
from typing import Dict, List, Optional
34
from attr import define
45
from structlog import get_logger
56
from example_publisher.provider import Provider
6-
77
from example_publisher.providers.coin_gecko import CoinGecko
88
from example_publisher.config import Config
99
from example_publisher.providers.pyth_replicator import PythReplicator
@@ -45,6 +45,14 @@ def __init__(self, config: Config) -> None:
4545
)
4646
self.subscriptions: Dict[SubscriptionId, Product] = {}
4747
self.products: List[Product] = []
48+
self.last_successful_update: Optional[float] = None
49+
50+
def is_healthy(self) -> bool:
51+
return (
52+
self.last_successful_update is not None
53+
and time.time() - self.last_successful_update
54+
< self.config.health_check_threshold_secs
55+
)
4856

4957
async def start(self):
5058
await self.pythd.connect()
@@ -141,6 +149,11 @@ async def on_notify_price_sched(self, subscription: int) -> None:
141149
await self.pythd.update_price(
142150
product.price_account, scaled_price, scaled_conf, TRADING
143151
)
152+
self.last_successful_update = (
153+
price.timestamp
154+
if self.last_successful_update is None
155+
else max(self.last_successful_update, price.timestamp)
156+
)
144157

145158
def apply_exponent(self, x: float, exp: int) -> int:
146159
return int(x * (10 ** (-exp)))

0 commit comments

Comments
 (0)