55# dependencies = [
66# "pyln-client>=24.11",
77# "requests[socks]>=2.23.0",
8+ # "portalocker>=3.2,<4",
89# ]
910# ///
1011
1112import requests
1213import sys
1314import time
1415
15- from requests .packages .urllib3 .util .retry import Retry
1616from requests .adapters import HTTPAdapter
17+ import os
18+ import base64
19+
1720from art import sauron_eye
21+ from requests .packages .urllib3 .util .retry import Retry
1822from pyln .client import Plugin
23+ import portalocker
24+
25+ from ratelimit import GlobalRateLimiter
26+ from shared_cache import SharedRequestCache
1927
2028
2129plugin = Plugin (dynamic = False )
@@ -27,25 +35,95 @@ class SauronError(Exception):
2735 pass
2836
2937
30- def fetch (url ):
38+ rate_limiter = GlobalRateLimiter (rate_per_second = 1 , max_wait_seconds = 15 )
39+ cache = SharedRequestCache (ttl_seconds = 30 )
40+
41+
42+ def fetch (plugin , url ):
3143 """Fetch this {url}, maybe through a pre-defined proxy."""
44+
3245 # FIXME: Maybe try to be smart and renew circuit to broadcast different
3346 # transactions ? Hint: lightningd will agressively send us the same
3447 # transaction a certain amount of times.
35- session = requests .session ()
36- session .proxies = plugin .sauron_socks_proxies
37- retry_strategy = Retry (
38- backoff_factor = 1 ,
39- total = 10 ,
40- status_forcelist = [429 , 500 , 502 , 503 , 504 ],
41- allowed_methods = ["HEAD" , "GET" , "OPTIONS" ],
42- )
43- adapter = HTTPAdapter (max_retries = retry_strategy )
44-
45- session .mount ("https://" , adapter )
46- session .mount ("http://" , adapter )
47-
48- return session .get (url )
48+ plugin .log (f"Making cache key for { url } " , level = "debug" )
49+ key = cache .make_key (url , body = "fetch" )
50+ lock_file = f"/tmp/fetch_lock_{ key } .lock"
51+
52+ # Fast path
53+ plugin .log (f"Checking cache for { url } " , level = "debug" )
54+ cached = cache .get (key )
55+ if cached :
56+ plugin .log (f"Cache hit for { url } " , level = "debug" )
57+ resp = requests .Response ()
58+ resp .status_code = cached ["status" ]
59+ resp ._content = base64 .b64decode (cached ["content_b64" ])
60+ resp .headers = cached ["headers" ]
61+ return resp
62+
63+ # Lock per URL
64+ os .makedirs ("/tmp" , exist_ok = True )
65+
66+ max_retries = 10
67+
68+ for attempt in range (max_retries + 1 ):
69+ try :
70+ plugin .log (f"Getting fetch lock for { url } " , level = "debug" )
71+ with portalocker .Lock (lock_file , timeout = 20 ):
72+ # Inside lock, re-check cache
73+ plugin .log (f"Re-checking cache for { url } " , level = "debug" )
74+ cached = cache .get (key )
75+ if cached :
76+ plugin .log (f"Cache hit for { url } " , level = "debug" )
77+ resp = requests .Response ()
78+ resp .status_code = cached ["status" ]
79+ resp ._content = base64 .b64decode (cached ["content_b64" ])
80+ resp .headers = cached ["headers" ]
81+ return resp
82+
83+ plugin .log ("Waiting for rate limit" , level = "debug" )
84+ rate_limiter .acquire ()
85+ plugin .log ("Rate limit acquired" , level = "debug" )
86+
87+ start = time .time ()
88+ plugin .log (f"Opening URL: { url } " , level = "debug" )
89+
90+ session = requests .session ()
91+ session .proxies = plugin .sauron_socks_proxies
92+ retry_strategy = Retry (
93+ backoff_factor = 1 ,
94+ total = 10 ,
95+ status_forcelist = [429 , 500 , 502 , 503 , 504 ],
96+ allowed_methods = ["HEAD" , "GET" , "OPTIONS" ],
97+ )
98+ adapter = HTTPAdapter (max_retries = retry_strategy )
99+
100+ session .mount ("https://" , adapter )
101+ session .mount ("http://" , adapter )
102+
103+ resp = session .get (url , timeout = (5 , 10 ))
104+
105+ elapsed = time .time () - start
106+ plugin .log (f"Request took { elapsed :.3f} s" , level = "debug" )
107+
108+ cache .set (
109+ key ,
110+ {
111+ "status" : resp .status_code ,
112+ "headers" : dict (resp .headers ),
113+ "content_b64" : base64 .b64encode (resp .content ).decode ("ascii" ),
114+ },
115+ )
116+
117+ return resp
118+
119+ except portalocker .exceptions .LockException :
120+ plugin .log (f"Timeout waiting for request lock for { url } " )
121+ time .sleep (0.5 )
122+ continue
123+
124+ except Exception as e :
125+ plugin .log (f"Failed: { e } " , level = "error" )
126+ raise
49127
50128
51129@plugin .init ()
@@ -80,7 +158,7 @@ def getchaininfo(plugin, **kwargs):
80158 "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6" : "signet" ,
81159 }
82160
83- genesis_req = fetch (blockhash_url )
161+ genesis_req = fetch (plugin , blockhash_url )
84162 if not genesis_req .status_code == 200 :
85163 raise SauronError (
86164 "Endpoint at {} returned {} ({}) when trying to "
@@ -89,7 +167,7 @@ def getchaininfo(plugin, **kwargs):
89167 )
90168 )
91169
92- blockcount_req = fetch (blockcount_url )
170+ blockcount_req = fetch (plugin , blockcount_url )
93171 if not blockcount_req .status_code == 200 :
94172 raise SauronError (
95173 "Endpoint at {} returned {} ({}) when trying to get blockcount." .format (
@@ -113,7 +191,7 @@ def getchaininfo(plugin, **kwargs):
113191@plugin .method ("getrawblockbyheight" )
114192def getrawblock (plugin , height , ** kwargs ):
115193 blockhash_url = "{}/block-height/{}" .format (plugin .api_endpoint , height )
116- blockhash_req = fetch (blockhash_url )
194+ blockhash_req = fetch (plugin , blockhash_url )
117195 if blockhash_req .status_code != 200 :
118196 return {
119197 "blockhash" : None ,
@@ -122,7 +200,7 @@ def getrawblock(plugin, height, **kwargs):
122200
123201 block_url = "{}/block/{}/raw" .format (plugin .api_endpoint , blockhash_req .text )
124202 while True :
125- block_req = fetch (block_url )
203+ block_req = fetch (plugin , block_url )
126204 if block_req .status_code != 200 :
127205 return {
128206 "blockhash" : None ,
@@ -168,14 +246,14 @@ def getutxout(plugin, txid, vout, **kwargs):
168246 gettx_url = "{}/tx/{}" .format (plugin .api_endpoint , txid )
169247 status_url = "{}/tx/{}/outspend/{}" .format (plugin .api_endpoint , txid , vout )
170248
171- gettx_req = fetch (gettx_url )
249+ gettx_req = fetch (plugin , gettx_url )
172250 if not gettx_req .status_code == 200 :
173251 raise SauronError (
174252 "Endpoint at {} returned {} ({}) when trying to get transaction." .format (
175253 gettx_url , gettx_req .status_code , gettx_req .text
176254 )
177255 )
178- status_req = fetch (status_url )
256+ status_req = fetch (plugin , status_url )
179257 if not status_req .status_code == 200 :
180258 raise SauronError (
181259 "Endpoint at {} returned {} ({}) when trying to get utxo status." .format (
@@ -200,7 +278,7 @@ def getutxout(plugin, txid, vout, **kwargs):
200278def estimatefees (plugin , ** kwargs ):
201279 feerate_url = "{}/fee-estimates" .format (plugin .api_endpoint )
202280
203- feerate_req = fetch (feerate_url )
281+ feerate_req = fetch (plugin , feerate_url )
204282 assert feerate_req .status_code == 200
205283 feerates = feerate_req .json ()
206284 if plugin .sauron_network in ["test" , "signet" ]:
0 commit comments