LittlePlyResearch notebook

transport.py

python · 102 lines · Exact bytes

Line 1 ↗
Version & provenance · service-20261028

Recorded SHA-256 a671b9e6a5b0a1437132e81efc208870fedb4ebb9c0f00a869a963c684639cfaRendered SHA-256 a671b9e6a5b0a1437132e81efc208870fedb4ebb9c0f00a869a963c684639cfa

Apache-2.0 for original LittlePly code, declared by the project owner on 2026-10-28. Historical source bytes and hashes are unchanged. Third-party components retain their own licenses; Lichess data remain CC0-1.0. License notes. Line links are stable within this snapshot. Public service source captured on 2026-10-28 after the runtime owner verified CPU-only systemd serving active with zero restarts. One concurrent public casual standard-start game; 10-30 minute initial clocks and 5-30 second increments. Availability depends on the local Windows/Ubuntu host. This is an allowlisted service subset, not a complete portable deployment bundle: launch, release-management scripts and private state are excluded. Source hashes identify this snapshot, not new training or evaluation results.

1"""Fixed-origin Lichess transport. Bounded asynchronous control requests."""
2import json,queue,re,threading,time
3from pathlib import Path
4import requests
5
6class Events:
7    """Game updates cannot be starved by a burst of public challenges."""
8    def __init__(self):self.game=queue.Queue(128);self.account=queue.Queue(32)
9    def put_nowait(self,item):
10        (self.account if item[0]=='account' else self.game).put_nowait(item)
11    def get_nowait(self):
12        try:return self.game.get_nowait()
13        except queue.Empty:return self.account.get_nowait()
14
15class Cooldown:
16    def __init__(self):self.until=0.;self.lock=threading.Lock()
17    def defer(self,seconds=60):
18        with self.lock:self.until=max(self.until,time.monotonic()+max(60,seconds))
19    def remaining(self):return max(0.,self.until-time.monotonic())
20
21class Transport:
22    def __init__(self,token_file,request=requests.request):
23        token=Path(token_file).read_text(encoding='utf-8-sig').strip()
24        if not re.fullmatch(r'[A-Za-z0-9_]+',token):raise ValueError('invalid token format')
25        self.headers={'Authorization':'Bearer '+token,'User-Agent':'LittlePly/1.0','Accept':'application/json'}
26        self.request=request;self.cooldown=Cooldown();self.stop=threading.Event()
27        self.jobs=queue.PriorityQueue(32);self.results=queue.Queue(64);self.counter=0;self.lock=threading.Lock()
28        self.thread=threading.Thread(target=self._run,daemon=True);self.thread.start()
29    def submit(self,key,method,path,data=None,priority=1,guard=None):
30        if not re.fullmatch(r'/api/[A-Za-z0-9_/?=&.-]+',path):raise ValueError('invalid API path')
31        with self.lock:self.counter+=1;n=self.counter
32        self.jobs.put_nowait((priority,n,(key,method,path,data,guard)))
33    def _run(self):
34        while not self.stop.is_set():
35            if self.stop.wait(min(.2,self.cooldown.remaining())):return
36            if self.cooldown.remaining():continue
37            try:priority,n,job=self.jobs.get(timeout=.2)
38            except queue.Empty:continue
39            key,method,path,data,guard=job
40            if guard and not guard():self.results.put((key,'cancelled',None));continue
41            try:
42                with self.request(method,'https://lichess.org'+path,headers=self.headers,data=data,
43                                  timeout=(3,5),allow_redirects=False) as r:
44                    code=r.status_code
45                    if code==429:
46                        try:retry=float(r.headers.get('Retry-After',60))
47                        except (TypeError,ValueError):retry=60
48                        self.cooldown.defer(retry)
49                        # Known rejected: return for state-aware retry after shared cooldown.
50                        result=('rate_limited',{'retry_after':max(60,retry)})
51                    elif code in (401,403):result=('auth_error',code)
52                    elif 200<=code<300:
53                        try:body=r.json()
54                        except ValueError:body={}
55                        result=('ok',body)
56                    elif 400<=code<500:result=('rejected',code)
57                    else:result=('uncertain' if method=='POST' else 'retryable',code)
58            except requests.RequestException as exc:
59                result=('uncertain' if method=='POST' else 'retryable',type(exc).__name__)
60            except Exception as exc:result=('retryable',type(exc).__name__)
61            self.results.put((key,*result))
62    def close(self):self.stop.set();self.thread.join(timeout=9)
63
64class Stream:
65    """One stream thread, monotonically increasing epochs, bounded queue."""
66    def __init__(self,transport,path,tag,events,epochs):
67        self.transport=transport;self.path=path;self.tag=tag;self.events=events;self.epochs=epochs
68        self.stop=threading.Event();self.resync=threading.Event();self.last_event=0.;self.response=None
69        self.thread=threading.Thread(target=self.run,daemon=True);self.thread.start()
70    def emit(self,kind,data,epoch):
71        try:self.events.put_nowait((self.tag,kind,data,epoch,time.monotonic()))
72        except queue.Full:self.resync.set()
73    def run(self):
74        delay=2
75        while not self.stop.is_set():
76            if self.stop.wait(max(0,self.transport.cooldown.remaining())):return
77            epoch=next(self.epochs);self.resync.clear()
78            try:
79                with requests.get('https://lichess.org'+self.path,headers=self.transport.headers,
80                                  stream=True,timeout=(5,15),allow_redirects=False) as r:
81                    self.response=r
82                    if r.status_code==429:self.transport.cooldown.defer();raise RuntimeError('rate_limited')
83                    if r.status_code in (401,403):self.emit('auth_error',r.status_code,epoch);return
84                    if r.status_code!=200:raise RuntimeError('stream_http')
85                    self.last_event=time.monotonic();self.emit('connected',{},epoch);delay=2
86                    for line in r.iter_lines(chunk_size=1):
87                        self.last_event=time.monotonic()
88                        if self.stop.is_set() or self.resync.is_set():break
89                        if len(line)>262144:raise ValueError('oversized stream record')
90                        if line:
91                            data=json.loads(line)
92                            if data.get('type') in ('gameFull','gameState','gameStart','gameFinish','challenge','challengeCanceled','challengeDeclined'):
93                                self.emit('data',data,epoch)
94            except Exception as exc:self.emit('disconnected',type(exc).__name__,epoch)
95            finally:self.response=None
96            if self.stop.wait(delay):return
97            delay=min(60,delay*2)
98    def close(self):
99        self.stop.set()
100        if self.response:self.response.close()
101        self.thread.join(timeout=16)
102