Sadeep Sachintha commited on
Commit
f53ddba
·
1 Parent(s): 09f1e96

feat: implement key-less FX service with native CBSL web scraping and fallback cache logic

Browse files
Files changed (5) hide show
  1. README.md +7 -9
  2. main.py +1 -1
  3. rate_cache.json +20 -20
  4. scratch/test_fx_service.py +1 -12
  5. services/fx_service.py +21 -69
README.md CHANGED
@@ -21,9 +21,9 @@ FlyRates is a high-availability Telegram bot designed for real-time currency exc
21
  - **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution.
22
  - **Database Flexibility:** Uses SQLAlchemy (supports both SQLite and PostgreSQL).
23
 
24
- ## Self-Healing Multi-Tier FX Service & CBSL Web Scraper 💱
25
 
26
- To permanently resolve external API rate-limiting errors (`status 429`) and guarantee 100% uptime for rate tracking, FlyRates incorporates a custom **Self-Healing Multi-Tier FX Rates Architecture**:
27
 
28
  1. **Official CBSL Web Scraper (Preferred Source for LKR):**
29
  - **How it Works:** Scrapes daily exchange rates directly from the official Central Bank of Sri Lanka website iframe endpoint (`/cbsl_custom/exrates/exrates.php`) using Python's built-in, lightweight `html.parser.HTMLParser`.
@@ -32,19 +32,17 @@ To permanently resolve external API rate-limiting errors (`status 429`) and guar
32
 
33
  2. **Persistent Disk Caching (`rate_cache.json`):**
34
  - Saves exchange rates to a local `rate_cache.json` file in the workspace root, preserving the cache across container redeployments and restarts.
35
- - Using a **1-hour TTL**, it completely bypasses API limits, serving subsequent dashboard and bot queries instantaneously in **under 0.03 milliseconds** from memory.
36
 
37
  3. **Bridge-Rate Derivation:**
38
  - Cross-currency rates (e.g. `USD` to `EUR`) that do not directly involve LKR are mathematically derived on the fly using LKR as a bridge from the official CBSL rates:
39
  $$\text{rate}(\text{USD} \rightarrow \text{EUR}) = \frac{\text{rate}(\text{USD} \rightarrow \text{LKR})}{\text{rate}(\text{EUR} \rightarrow \text{LKR})}$$
40
- - This makes the entire bot and dashboard **100% functional and key-less**!
41
 
42
- 4. **Self-Healing Fallback Hierarchy:**
43
  - **Tier 1:** Valid Cache (Memory/Disk `rate_cache.json`)
44
- - **Tier 2:** Official CBSL Scraper (for LKR-based pairs)
45
- - **Tier 3:** Custom ExchangeRate-API (Premium v6 key, if set in `.env`)
46
- - **Tier 4:** Key-less Free Fallback API (`open.er-api.com`)
47
- - **Tier 5:** Stale Cache Fallback (serves expired cached rates if all networks/APIs are down)
48
 
49
  5. **Asynchronous Concurrency:**
50
  - The `/api/stats` endpoint uses `asyncio.gather` to retrieve live exchange rates concurrently, boosting cold-cache performance by over **5x**.
 
21
  - **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution.
22
  - **Database Flexibility:** Uses SQLAlchemy (supports both SQLite and PostgreSQL).
23
 
24
+ ## Key-less Web-Scraping FX Service & CBSL Web Scraper 💱
25
 
26
+ To permanently resolve external API rate-limiting errors (`status 429`) and guarantee 100% uptime for rate tracking, FlyRates incorporates a custom **100% Web-Scraping FX Rates Architecture**:
27
 
28
  1. **Official CBSL Web Scraper (Preferred Source for LKR):**
29
  - **How it Works:** Scrapes daily exchange rates directly from the official Central Bank of Sri Lanka website iframe endpoint (`/cbsl_custom/exrates/exrates.php`) using Python's built-in, lightweight `html.parser.HTMLParser`.
 
32
 
33
  2. **Persistent Disk Caching (`rate_cache.json`):**
34
  - Saves exchange rates to a local `rate_cache.json` file in the workspace root, preserving the cache across container redeployments and restarts.
35
+ - Using a **1-hour TTL**, it completely bypasses external dependencies, serving subsequent dashboard and bot queries instantaneously in **under 0.03 milliseconds** from memory.
36
 
37
  3. **Bridge-Rate Derivation:**
38
  - Cross-currency rates (e.g. `USD` to `EUR`) that do not directly involve LKR are mathematically derived on the fly using LKR as a bridge from the official CBSL rates:
39
  $$\text{rate}(\text{USD} \rightarrow \text{EUR}) = \frac{\text{rate}(\text{USD} \rightarrow \text{LKR})}{\text{rate}(\text{EUR} \rightarrow \text{LKR})}$$
40
+ - This makes the entire bot and dashboard **100% functional, key-less, and free**!
41
 
42
+ 4. **Robust Fallback Hierarchy:**
43
  - **Tier 1:** Valid Cache (Memory/Disk `rate_cache.json`)
44
+ - **Tier 2:** Official CBSL Scraper (real-time scraping and dynamic bridging)
45
+ - **Tier 3:** Stale Cache Fallback (serves expired cached rates or mathematically derives stale bridge rates if CBSL is unreachable)
 
 
46
 
47
  5. **Asynchronous Concurrency:**
48
  - The `/api/stats` endpoint uses `asyncio.gather` to retrieve live exchange rates concurrently, boosting cold-cache performance by over **5x**.
main.py CHANGED
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
58
  diagnostic_results.append("Starting resolution & connection diagnostics...")
59
 
60
  # Check DNS resolution
61
- for host in ["api.telegram.org", "v6.exchangerate-api.com"]:
62
  try:
63
  ips = socket.getaddrinfo(host, 443)
64
  diagnostic_results.append(f"DNS Resolution for {host}: {ips}")
 
58
  diagnostic_results.append("Starting resolution & connection diagnostics...")
59
 
60
  # Check DNS resolution
61
+ for host in ["api.telegram.org", "www.cbsl.gov.lk"]:
62
  try:
63
  ips = socket.getaddrinfo(host, 443)
64
  diagnostic_results.append(f"DNS Resolution for {host}: {ips}")
rate_cache.json CHANGED
@@ -1,82 +1,82 @@
1
  {
2
  "USD_LKR": {
3
  "rate": 324.7184,
4
- "timestamp": 1779032559.9410503
5
  },
6
  "LKR_USD": {
7
  "rate": 0.003079591424446536,
8
- "timestamp": 1779032559.9410558
9
  },
10
  "EUR_LKR": {
11
  "rate": 378.3294,
12
- "timestamp": 1779032559.94107
13
  },
14
  "LKR_EUR": {
15
  "rate": 0.0026431992861247365,
16
- "timestamp": 1779032559.9410727
17
  },
18
  "GBP_LKR": {
19
  "rate": 434.116,
20
- "timestamp": 1779032559.9410799
21
  },
22
  "LKR_GBP": {
23
  "rate": 0.0023035317749173032,
24
- "timestamp": 1779032559.941081
25
  },
26
  "AUD_LKR": {
27
  "rate": 233.4401,
28
- "timestamp": 1779032559.9410868
29
  },
30
  "LKR_AUD": {
31
  "rate": 0.0042837541622026375,
32
- "timestamp": 1779032559.941088
33
  },
34
  "JPY_LKR": {
35
  "rate": 2.0486,
36
- "timestamp": 1779032559.9410925
37
  },
38
  "LKR_JPY": {
39
  "rate": 0.48813824074978035,
40
- "timestamp": 1779032559.9410937
41
  },
42
  "AED_LKR": {
43
  "rate": 88.4045,
44
- "timestamp": 1779032559.9410985
45
  },
46
  "LKR_AED": {
47
  "rate": 0.011311641375721824,
48
- "timestamp": 1779032559.9411001
49
  },
50
  "SAR_LKR": {
51
  "rate": 86.5316,
52
- "timestamp": 1779032559.9411047
53
  },
54
  "LKR_SAR": {
55
  "rate": 0.011556471855368443,
56
- "timestamp": 1779032559.9411058
57
  },
58
  "INR_LKR": {
59
  "rate": 3.3908,
60
- "timestamp": 1779032559.9411101
61
  },
62
  "LKR_INR": {
63
  "rate": 0.29491565412292087,
64
- "timestamp": 1779032559.941111
65
  },
66
  "CNY_LKR": {
67
  "rate": 47.7682,
68
- "timestamp": 1779032559.9411154
69
  },
70
  "LKR_CNY": {
71
  "rate": 0.020934429180919523,
72
- "timestamp": 1779032559.9411163
73
  },
74
  "QAR_LKR": {
75
  "rate": 89.0738,
76
- "timestamp": 1779032559.9411206
77
  },
78
  "LKR_QAR": {
79
  "rate": 0.011226645770136672,
80
- "timestamp": 1779032559.9411216
81
  }
82
  }
 
1
  {
2
  "USD_LKR": {
3
  "rate": 324.7184,
4
+ "timestamp": 1779042576.8926218
5
  },
6
  "LKR_USD": {
7
  "rate": 0.003079591424446536,
8
+ "timestamp": 1779042576.8926272
9
  },
10
  "EUR_LKR": {
11
  "rate": 378.3294,
12
+ "timestamp": 1779042576.8926556
13
  },
14
  "LKR_EUR": {
15
  "rate": 0.0026431992861247365,
16
+ "timestamp": 1779042576.8926578
17
  },
18
  "GBP_LKR": {
19
  "rate": 434.116,
20
+ "timestamp": 1779042576.8926651
21
  },
22
  "LKR_GBP": {
23
  "rate": 0.0023035317749173032,
24
+ "timestamp": 1779042576.8926666
25
  },
26
  "AUD_LKR": {
27
  "rate": 233.4401,
28
+ "timestamp": 1779042576.8926735
29
  },
30
  "LKR_AUD": {
31
  "rate": 0.0042837541622026375,
32
+ "timestamp": 1779042576.892675
33
  },
34
  "JPY_LKR": {
35
  "rate": 2.0486,
36
+ "timestamp": 1779042576.892681
37
  },
38
  "LKR_JPY": {
39
  "rate": 0.48813824074978035,
40
+ "timestamp": 1779042576.8926826
41
  },
42
  "AED_LKR": {
43
  "rate": 88.4045,
44
+ "timestamp": 1779042576.8926892
45
  },
46
  "LKR_AED": {
47
  "rate": 0.011311641375721824,
48
+ "timestamp": 1779042576.892691
49
  },
50
  "SAR_LKR": {
51
  "rate": 86.5316,
52
+ "timestamp": 1779042576.8926976
53
  },
54
  "LKR_SAR": {
55
  "rate": 0.011556471855368443,
56
+ "timestamp": 1779042576.8926988
57
  },
58
  "INR_LKR": {
59
  "rate": 3.3908,
60
+ "timestamp": 1779042576.8927054
61
  },
62
  "LKR_INR": {
63
  "rate": 0.29491565412292087,
64
+ "timestamp": 1779042576.8927069
65
  },
66
  "CNY_LKR": {
67
  "rate": 47.7682,
68
+ "timestamp": 1779042576.8927135
69
  },
70
  "LKR_CNY": {
71
  "rate": 0.020934429180919523,
72
+ "timestamp": 1779042576.892715
73
  },
74
  "QAR_LKR": {
75
  "rate": 89.0738,
76
+ "timestamp": 1779042576.8927214
77
  },
78
  "LKR_QAR": {
79
  "rate": 0.011226645770136672,
80
+ "timestamp": 1779042576.8927226
81
  }
82
  }
scratch/test_fx_service.py CHANGED
@@ -74,17 +74,8 @@ async def test_fx_service():
74
  print(" [Simulated Scraper Failure]")
75
  return False
76
  fx_service.fetch_cbsl_rates = broken_fetch
77
-
78
- # We will also temporarily unset API key and break ER-API call
79
- original_api_key = fx_service.api_key
80
- fx_service.api_key = "" # Unset key
81
- original_fetch_api = fx_service.fetch_exchangerate_api
82
- async def broken_api(base):
83
- print(" [Simulated API Failure]")
84
- return False
85
- fx_service.fetch_exchangerate_api = broken_api
86
 
87
- print("Cache is expired. External scrapers and APIs are simulated as down.")
88
  start_time = time.time()
89
  stale_rate = await fx_service.get_rate("USD", "LKR")
90
  elapsed = time.time() - start_time
@@ -94,8 +85,6 @@ async def test_fx_service():
94
 
95
  # Restore original methods
96
  fx_service.fetch_cbsl_rates = original_fetch
97
- fx_service.api_key = original_api_key
98
- fx_service.fetch_exchangerate_api = original_fetch_api
99
 
100
  print("==================================================")
101
  print(" ALL FXSERVICE TESTS PASSED!")
 
74
  print(" [Simulated Scraper Failure]")
75
  return False
76
  fx_service.fetch_cbsl_rates = broken_fetch
 
 
 
 
 
 
 
 
 
77
 
78
+ print("Cache is expired. External scraper is simulated as down.")
79
  start_time = time.time()
80
  stale_rate = await fx_service.get_rate("USD", "LKR")
81
  elapsed = time.time() - start_time
 
85
 
86
  # Restore original methods
87
  fx_service.fetch_cbsl_rates = original_fetch
 
 
88
 
89
  print("==================================================")
90
  print(" ALL FXSERVICE TESTS PASSED!")
services/fx_service.py CHANGED
@@ -63,9 +63,6 @@ class CBSLTableParser(HTMLParser):
63
 
64
  class FXService:
65
  def __init__(self):
66
- self.api_key = settings.fx_api_key
67
- # Premium exchangerate-api URL
68
- self.base_url = f"https://v6.exchangerate-api.com/v6/{self.api_key}"
69
  self.cache: Dict[str, Dict] = {}
70
  self.load_cache()
71
 
@@ -227,50 +224,6 @@ class FXService:
227
  logger.error(f"Global exception in fetch_cbsl_rates: {e}")
228
  return False
229
 
230
- async def fetch_exchangerate_api(self, base_currency: str) -> bool:
231
- """
232
- Fetches latest exchange rates from ExchangeRate-API.
233
- First tries the user's custom premium key. If rate-limited or fails, automatically
234
- falls back to the free, key-less open endpoint (open.er-api.com).
235
- """
236
- base_currency = base_currency.upper()
237
- connector = aiohttp.TCPConnector(family=socket.AF_INET)
238
-
239
- # 1. Custom premium key URL
240
- premium_url = f"{self.base_url}/latest/{base_currency}"
241
- # 2. Free open fallback URL
242
- fallback_url = f"https://open.er-api.com/v6/latest/{base_currency}"
243
-
244
- endpoints = []
245
- if self.api_key:
246
- endpoints.append(("Premium Key", premium_url, "conversion_rates"))
247
- endpoints.append(("Free Open Fallback", fallback_url, "rates"))
248
-
249
- async with aiohttp.ClientSession(connector=connector) as session:
250
- for name, url, rates_key in endpoints:
251
- logger.info(f"Fetching rates using {name} from {url}...")
252
- try:
253
- async with session.get(url, timeout=10) as response:
254
- if response.status == 200:
255
- data = await response.json()
256
- rates = data.get(rates_key, {})
257
- if rates:
258
- logger.info(f"Successfully fetched {len(rates)} rates using {name} for base {base_currency}")
259
- for target, val in rates.items():
260
- target = target.upper()
261
- if target in ALLOWED_CURRENCIES:
262
- self.update_cache_entry(base_currency, target, val)
263
- self.save_cache()
264
- return True
265
- elif response.status == 429:
266
- logger.error(f"{name} rate-limited! Status: 429 Too Many Requests.")
267
- else:
268
- logger.error(f"{name} returned error status: {response.status}")
269
- except Exception as e:
270
- logger.error(f"Exception fetching {name} for base {base_currency}: {e}")
271
-
272
- return False
273
-
274
  async def get_rate(self, base_currency: str, target_currency: str) -> Optional[float]:
275
  """
276
  Retrieves real-time exchange rate with support for in-memory caching,
@@ -304,29 +257,17 @@ class FXService:
304
  return derived
305
 
306
  # 4. Cache MISS: Retrieve fresh rates.
307
- # If either base or target is LKR, attempt CBSL Scraper first (preferred, official, and completely free).
308
- if base == "LKR" or target == "LKR":
309
- logger.info(f"Cache miss for LKR conversion {base}->{target}. Launching CBSL Web Scraper...")
310
- cbsl_success = await self.fetch_cbsl_rates()
311
- if cbsl_success:
312
- res = self.get_cached_rate(base, target)
313
- if res is not None:
314
- return res
315
-
316
- # 5. Fallback/Alternative: If CBSL failed or we need a non-LKR rate and bridge was not in cache,
317
- # fetch from ExchangeRate-API.
318
- logger.info(f"Fetching from ExchangeRate-API for {base}...")
319
- api_success = await self.fetch_exchangerate_api(base)
320
- if api_success:
321
  res = self.get_cached_rate(base, target)
322
  if res is not None:
323
  return res
324
-
325
- # 6. Still failed: Try CBSL if we haven't done it (for non-LKR pairs, we can fetch CBSL and try to bridge)
326
- if base != "LKR" and target != "LKR":
327
- logger.info("Attempting CBSL scrape as fallback to calculate cross rate...")
328
- cbsl_success = await self.fetch_cbsl_rates()
329
- if cbsl_success:
330
  base_lkr = self.get_cached_rate(base, "LKR")
331
  target_lkr = self.get_cached_rate(target, "LKR")
332
  if base_lkr is not None and target_lkr is not None and target_lkr > 0:
@@ -336,13 +277,24 @@ class FXService:
336
  logger.info(f"Derived cross rate {base}->{target} after CBSL scrape: {derived}")
337
  return derived
338
 
339
- # 7. Total Failure: Fallback to stale/expired cached rate if it exists
340
  logger.warning(f"All live FX sources failed for {base}->{target}. Attempting stale cache fallback...")
341
  stale_rate = self.get_stale_rate(base, target)
342
  if stale_rate is not None:
343
  return stale_rate
344
 
345
- # 8. Absolute Failure: No rates available
 
 
 
 
 
 
 
 
 
 
 
346
  logger.critical(f"No rates available in memory, disk, or APIs for {base}->{target}!")
347
  return None
348
 
 
63
 
64
  class FXService:
65
  def __init__(self):
 
 
 
66
  self.cache: Dict[str, Dict] = {}
67
  self.load_cache()
68
 
 
224
  logger.error(f"Global exception in fetch_cbsl_rates: {e}")
225
  return False
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  async def get_rate(self, base_currency: str, target_currency: str) -> Optional[float]:
228
  """
229
  Retrieves real-time exchange rate with support for in-memory caching,
 
257
  return derived
258
 
259
  # 4. Cache MISS: Retrieve fresh rates.
260
+ # Since we are fully using web scraping, any cache miss should trigger CBSL scraper.
261
+ # CBSL scraper fetches all rates (against LKR) and populates them into the cache.
262
+ logger.info(f"Cache miss for {base}->{target}. Launching CBSL Web Scraper...")
263
+ cbsl_success = await self.fetch_cbsl_rates()
264
+ if cbsl_success:
265
+ # Try to get rate directly from cache (if either is LKR)
 
 
 
 
 
 
 
 
266
  res = self.get_cached_rate(base, target)
267
  if res is not None:
268
  return res
269
+ # Or try to derive cross rate mathematically if both are non-LKR
270
+ if base != "LKR" and target != "LKR":
 
 
 
 
271
  base_lkr = self.get_cached_rate(base, "LKR")
272
  target_lkr = self.get_cached_rate(target, "LKR")
273
  if base_lkr is not None and target_lkr is not None and target_lkr > 0:
 
277
  logger.info(f"Derived cross rate {base}->{target} after CBSL scrape: {derived}")
278
  return derived
279
 
280
+ # 5. Total Failure: Fallback to stale/expired cached rate if it exists
281
  logger.warning(f"All live FX sources failed for {base}->{target}. Attempting stale cache fallback...")
282
  stale_rate = self.get_stale_rate(base, target)
283
  if stale_rate is not None:
284
  return stale_rate
285
 
286
+ # If we failed to get a direct stale rate, try to compute it from stale LKR rates
287
+ if base != "LKR" and target != "LKR":
288
+ base_lkr_stale = self.get_stale_rate(base, "LKR")
289
+ target_lkr_stale = self.get_stale_rate(target, "LKR")
290
+ if base_lkr_stale is not None and target_lkr_stale is not None and target_lkr_stale > 0:
291
+ derived = base_lkr_stale / target_lkr_stale
292
+ self.update_cache_entry(base, target, derived)
293
+ self.save_cache()
294
+ logger.warning(f"Derived stale cross rate {base}->{target} from stale LKR rates: {derived}")
295
+ return derived
296
+
297
+ # 6. Absolute Failure: No rates available
298
  logger.critical(f"No rates available in memory, disk, or APIs for {base}->{target}!")
299
  return None
300