recapi 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recapi/__init__.py +38 -0
- recapi/async_client.py +95 -0
- recapi/client.py +1018 -0
- recapi/history.py +269 -0
- recapi-0.4.1.dist-info/METADATA +212 -0
- recapi-0.4.1.dist-info/RECORD +9 -0
- recapi-0.4.1.dist-info/WHEEL +5 -0
- recapi-0.4.1.dist-info/licenses/LICENSE +21 -0
- recapi-0.4.1.dist-info/top_level.txt +1 -0
recapi/client.py
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
"""The recapi client for Python servers: history, batching, retries and fallback.
|
|
2
|
+
|
|
3
|
+
It behaves like the JS SDK (``sdk/js``); ``spec/client_vectors.json`` pins what
|
|
4
|
+
both must agree on. Standard library only: it adds no dependency to an app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import base64
|
|
11
|
+
import contextlib
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import http.client
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import uuid
|
|
24
|
+
import weakref
|
|
25
|
+
from collections import OrderedDict
|
|
26
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Protocol
|
|
29
|
+
|
|
30
|
+
from .history import History, is_id
|
|
31
|
+
|
|
32
|
+
__version__ = "0.4.1"
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("recapi")
|
|
35
|
+
Tags = Sequence[str] | None
|
|
36
|
+
|
|
37
|
+
# API limits (docs/api.md).
|
|
38
|
+
MAX_EVENTS_PER_REQUEST = 1000
|
|
39
|
+
MAX_ITEMS_PER_REQUEST = 1000
|
|
40
|
+
MAX_EXCLUDE = 500
|
|
41
|
+
DEFAULT_COUNT = 10
|
|
42
|
+
MAX_COUNT = 100
|
|
43
|
+
# A widget still empty after 1.5 s has lost the user: recommend() gives up
|
|
44
|
+
# then and falls back, whatever the general timeout (which suits events).
|
|
45
|
+
RECOMMEND_TIMEOUT = 1.5
|
|
46
|
+
# The recommended level scale (docs/api.md "Interaction levels"), by name.
|
|
47
|
+
LEVEL_NAMES = {"view": 3, "cart": 5, "purchase": 10, "reject": -10}
|
|
48
|
+
# Events waiting for a send. A server tracks for every user at once, so this
|
|
49
|
+
# is ten times the JS SDK's queue: a minute-long outage at 150 events/s fits.
|
|
50
|
+
MAX_QUEUE = 10_000
|
|
51
|
+
MAX_FLUSH_BACKOFF = 60.0
|
|
52
|
+
RETRIES = 3
|
|
53
|
+
RETRY_BASE = 0.5
|
|
54
|
+
RETRY_MAX = 8.0
|
|
55
|
+
# Attribution windows, as in the JS SDK: an event on a shown item within 30
|
|
56
|
+
# minutes of the display is the click; after a click, that item's events are
|
|
57
|
+
# attributed for 24 hours.
|
|
58
|
+
DISPLAY_WINDOW_MS = 30 * 60 * 1000
|
|
59
|
+
CLICK_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
60
|
+
# Shown items remembered per user (the JS SDK keeps 500 for one browser; a
|
|
61
|
+
# server store holds every user's).
|
|
62
|
+
MAX_SHOWN_ITEMS = 100
|
|
63
|
+
PUBLIC_KEY_PREFIX = "rk_pub_"
|
|
64
|
+
# Open connections kept between calls: one per call in flight at the busiest
|
|
65
|
+
# moment (AsyncClient runs 8 at once), so a burst of threads can't leave
|
|
66
|
+
# hundreds of sockets open.
|
|
67
|
+
MAX_IDLE_CONNECTIONS = 16
|
|
68
|
+
|
|
69
|
+
_CODE_BY_STATUS = {
|
|
70
|
+
400: "invalid_request",
|
|
71
|
+
401: "unauthorized",
|
|
72
|
+
403: "forbidden",
|
|
73
|
+
404: "not_found",
|
|
74
|
+
413: "too_large",
|
|
75
|
+
429: "rate_limited",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Indirections the tests replace (clock, backoff sleep, jitter).
|
|
79
|
+
_now_ms = lambda: int(time.time() * 1000) # noqa: E731
|
|
80
|
+
_random = random.random
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sleep(seconds: float, stop: threading.Event | None = None) -> bool:
|
|
84
|
+
"""Wait ``seconds``; return True, at once, when ``stop`` is or becomes set."""
|
|
85
|
+
if stop is None:
|
|
86
|
+
time.sleep(seconds)
|
|
87
|
+
return False
|
|
88
|
+
return stop.wait(seconds)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RecapiError(Exception):
|
|
92
|
+
"""A failed call. ``status`` is 0 when no HTTP response was received.
|
|
93
|
+
|
|
94
|
+
``code`` is the API's error code (``invalid_request``, ``rate_limited``,
|
|
95
|
+
``quota_exceeded``...) or a client one: ``network``, ``timeout``,
|
|
96
|
+
``invalid_response``.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self, code: str, message: str, *, status: int = 0, retry_after: float | None = None
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(message)
|
|
103
|
+
self.code = code
|
|
104
|
+
self.status = status
|
|
105
|
+
self.retry_after = retry_after
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def retryable(self) -> bool:
|
|
109
|
+
"""Whether trying again later can succeed (``quota_exceeded`` can't until it resets)."""
|
|
110
|
+
if self.code in ("network", "timeout"):
|
|
111
|
+
return True
|
|
112
|
+
if self.status == 429:
|
|
113
|
+
return self.code != "quota_exceeded"
|
|
114
|
+
return self.status >= 500
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def outage(self) -> bool:
|
|
118
|
+
"""Whether recapi couldn't answer in time or failed on its side.
|
|
119
|
+
|
|
120
|
+
A 429 is the tenant's own limit and a 4xx an integration bug: a
|
|
121
|
+
fallback would hide both.
|
|
122
|
+
"""
|
|
123
|
+
return self.code in ("network", "timeout") or self.status >= 500
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Item:
|
|
128
|
+
"""One recommended or popular item."""
|
|
129
|
+
|
|
130
|
+
item_id: str
|
|
131
|
+
score: float
|
|
132
|
+
slot: str
|
|
133
|
+
#: The object stored with the item (``metadata`` of ``PUT /v1/items``:
|
|
134
|
+
#: name, picture, link, price...), None when the item has none. Left
|
|
135
|
+
#: out of the hash, a dict having none, so items still go in sets.
|
|
136
|
+
metadata: dict[str, Any] | None = field(default=None, hash=False)
|
|
137
|
+
#: Why the item is listed, a reason code to turn into a label for
|
|
138
|
+
#: visitors: "for_you", "similar", "next", "bought_together",
|
|
139
|
+
#: "trending", "popular", "new", "explore" or "more" (docs/api.md
|
|
140
|
+
#: "Reason codes"), None when the API sent none. New codes may be
|
|
141
|
+
#: added: show nothing for an unknown one.
|
|
142
|
+
why: str | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Recommendations:
|
|
147
|
+
"""A recommendation list. ``fallback`` lists are popular items with no id."""
|
|
148
|
+
|
|
149
|
+
recommendation_id: str | None
|
|
150
|
+
fallback: bool
|
|
151
|
+
items: list[Item]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Store(Protocol):
|
|
155
|
+
"""Where per-user state lives: a Redis client, Django's cache, memcached...
|
|
156
|
+
|
|
157
|
+
Values are strings; ``get`` returns None (or ``b""``) for a missing key.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def get(self, key: str) -> str | bytes | None: ... # noqa: D102
|
|
161
|
+
|
|
162
|
+
def set(self, key: str, value: str) -> object: ... # noqa: D102
|
|
163
|
+
|
|
164
|
+
def delete(self, key: str) -> object: ... # noqa: D102
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class MemoryStore:
|
|
168
|
+
"""In-process store keeping the most recently used ``max_users`` users."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, max_users: int = 10_000) -> None:
|
|
171
|
+
self._max = max(1, max_users)
|
|
172
|
+
self._data: OrderedDict[str, str] = OrderedDict()
|
|
173
|
+
self._lock = threading.Lock()
|
|
174
|
+
|
|
175
|
+
def get(self, key: str) -> str | None: # noqa: D102
|
|
176
|
+
with self._lock:
|
|
177
|
+
value = self._data.get(key)
|
|
178
|
+
if value is not None:
|
|
179
|
+
self._data.move_to_end(key)
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
def set(self, key: str, value: str) -> None: # noqa: D102
|
|
183
|
+
with self._lock:
|
|
184
|
+
self._data[key] = value
|
|
185
|
+
self._data.move_to_end(key)
|
|
186
|
+
while len(self._data) > self._max:
|
|
187
|
+
self._data.popitem(last=False)
|
|
188
|
+
|
|
189
|
+
def delete(self, key: str) -> None: # noqa: D102
|
|
190
|
+
with self._lock:
|
|
191
|
+
self._data.pop(key, None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _UserState:
|
|
195
|
+
"""What the store keeps per user: the history and the shown items.
|
|
196
|
+
|
|
197
|
+
``shown`` maps item -> [recommendation id, shown at, clicked at?], oldest
|
|
198
|
+
first. ``restored`` is set once the history holds recapi's stored one.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
__slots__ = ("history", "restored", "shown")
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self, history: History | None = None, shown: dict | None = None, restored: bool = False
|
|
205
|
+
) -> None:
|
|
206
|
+
self.history = history or History()
|
|
207
|
+
self.shown: dict[str, list[Any]] = shown or {}
|
|
208
|
+
self.restored = restored
|
|
209
|
+
|
|
210
|
+
def dumps(self, now: int) -> str:
|
|
211
|
+
shown = [[item, *entry] for item, entry in self.shown.items() if _attributable(entry, now)]
|
|
212
|
+
state = {"history": self.history.encode(), "shown": shown[-MAX_SHOWN_ITEMS:]}
|
|
213
|
+
if self.restored:
|
|
214
|
+
state["restored"] = True
|
|
215
|
+
return json.dumps(state, separators=(",", ":"))
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def loads(cls, raw: str | bytes | None) -> _UserState:
|
|
219
|
+
if not raw:
|
|
220
|
+
return cls()
|
|
221
|
+
try:
|
|
222
|
+
data = json.loads(raw)
|
|
223
|
+
history = History.decode(data["history"])
|
|
224
|
+
shown = {
|
|
225
|
+
e[0]: list(e[1:])
|
|
226
|
+
for e in data["shown"]
|
|
227
|
+
if isinstance(e, list) and 3 <= len(e) <= 4 and is_id(e[0])
|
|
228
|
+
}
|
|
229
|
+
except (ValueError, KeyError, TypeError):
|
|
230
|
+
history = None
|
|
231
|
+
if history is None:
|
|
232
|
+
log.warning("recapi: discarding a malformed stored user state")
|
|
233
|
+
return cls()
|
|
234
|
+
return cls(history, shown, data.get("restored") is True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _attributable(entry: Sequence[Any], t: int) -> bool:
|
|
238
|
+
if len(entry) < 3:
|
|
239
|
+
return t - entry[1] <= DISPLAY_WINDOW_MS
|
|
240
|
+
return t - entry[2] <= CLICK_WINDOW_MS
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Client:
|
|
244
|
+
"""A recapi client, safe to share between threads; create one per process.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
api_key: Secret key (``rk_sec_...``): a server can then restore a
|
|
248
|
+
user's history from recapi when the store lacks it.
|
|
249
|
+
base_url: Your recapi API origin, e.g. ``https://api.example.com``.
|
|
250
|
+
store: Per-user history and attribution state; defaults to an
|
|
251
|
+
in-memory :class:`MemoryStore`. Pass a shared store (Redis...) when
|
|
252
|
+
several processes serve the same users.
|
|
253
|
+
timeout: Per-request timeout in seconds (recommend() uses at most 1.5).
|
|
254
|
+
flush_interval: Seconds between background event sends.
|
|
255
|
+
max_batch: Events per request, at most the API's 1,000.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
api_key: str,
|
|
261
|
+
base_url: str,
|
|
262
|
+
*,
|
|
263
|
+
store: Store | None = None,
|
|
264
|
+
timeout: float = 5.0,
|
|
265
|
+
flush_interval: float = 2.0,
|
|
266
|
+
max_batch: int = MAX_EVENTS_PER_REQUEST,
|
|
267
|
+
) -> None:
|
|
268
|
+
if not isinstance(api_key, str) or not api_key:
|
|
269
|
+
raise ValueError("api_key is required")
|
|
270
|
+
if not isinstance(base_url, str) or not base_url:
|
|
271
|
+
raise ValueError("base_url is required")
|
|
272
|
+
self._api_key = api_key
|
|
273
|
+
self._base = base_url.rstrip("/")
|
|
274
|
+
url = urllib.parse.urlsplit(self._base)
|
|
275
|
+
if url.scheme not in ("http", "https") or not url.hostname:
|
|
276
|
+
raise ValueError("base_url must be an http(s) URL")
|
|
277
|
+
self._url = url
|
|
278
|
+
# HTTPS_PROXY, HTTP_PROXY and NO_PROXY, as urllib honours them.
|
|
279
|
+
proxy = None
|
|
280
|
+
if not urllib.request.proxy_bypass(url.hostname):
|
|
281
|
+
proxy = urllib.request.getproxies().get(url.scheme)
|
|
282
|
+
self._proxy = (
|
|
283
|
+
urllib.parse.urlsplit(proxy if "://" in proxy else "http://" + proxy) if proxy else None
|
|
284
|
+
)
|
|
285
|
+
self._proxy_auth = _basic_auth(self._proxy)
|
|
286
|
+
# Through a plain HTTP proxy, a request names the whole URL.
|
|
287
|
+
self._target_base = self._base if self._proxy and url.scheme == "http" else url.path
|
|
288
|
+
self._store: Store = store if store is not None else MemoryStore()
|
|
289
|
+
self._timeout = timeout
|
|
290
|
+
self._interval = flush_interval
|
|
291
|
+
self._max_batch = min(max(1, max_batch), MAX_EVENTS_PER_REQUEST)
|
|
292
|
+
# Two environments' keys may share one Redis: their users must not mix.
|
|
293
|
+
self._prefix = "recapi:" + hashlib.sha256(api_key.encode()).hexdigest()[:12] + ":"
|
|
294
|
+
self._init_threading()
|
|
295
|
+
ref = weakref.ref(self)
|
|
296
|
+
if hasattr(os, "register_at_fork"):
|
|
297
|
+
os.register_at_fork(after_in_child=lambda: _call(ref, "_init_threading"))
|
|
298
|
+
atexit.register(lambda: _call(ref, "close"))
|
|
299
|
+
|
|
300
|
+
def _init_threading(self) -> None:
|
|
301
|
+
# Also runs in a forked child (gunicorn, multiprocessing): the
|
|
302
|
+
# parent's queue is the parent's to send, its sender thread did not
|
|
303
|
+
# survive the fork, and a lock held then would stay held for ever.
|
|
304
|
+
self._user_locks = [threading.Lock() for _ in range(64)]
|
|
305
|
+
self._queue: list[dict[str, Any]] = []
|
|
306
|
+
self._cond = threading.Condition()
|
|
307
|
+
self._send_lock = threading.Lock()
|
|
308
|
+
self._thread: threading.Thread | None = None
|
|
309
|
+
self._failures = 0
|
|
310
|
+
self._closed = threading.Event()
|
|
311
|
+
# Keep-alive connections between calls, so the TLS handshake is paid
|
|
312
|
+
# once, not on every call. Shared by every thread, so a thread that
|
|
313
|
+
# ends (a thread per request) leaves its connection to the next call;
|
|
314
|
+
# a forked child must not share the parent's.
|
|
315
|
+
self._idle: list[http.client.HTTPConnection] = []
|
|
316
|
+
self._idle_lock = threading.Lock()
|
|
317
|
+
|
|
318
|
+
# --- events -------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def track(
|
|
321
|
+
self,
|
|
322
|
+
item_id: str,
|
|
323
|
+
level: int | str,
|
|
324
|
+
*,
|
|
325
|
+
user_id: str | None = None,
|
|
326
|
+
anon_id: str | None = None,
|
|
327
|
+
recommendation_id: str | None = None,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Record an interaction: the user's history changes at once, the event is queued.
|
|
330
|
+
|
|
331
|
+
``level`` is an integer -10 to 10 or ``view`` (3), ``cart`` (5),
|
|
332
|
+
``purchase`` (10), ``reject`` (-10: never recommend the item to this
|
|
333
|
+
user again until a positive event on it). ``recommendation_id``
|
|
334
|
+
overrides the automatic attribution.
|
|
335
|
+
"""
|
|
336
|
+
level = _level(level)
|
|
337
|
+
if not is_id(item_id):
|
|
338
|
+
raise ValueError("item_id must be 1-256 bytes of UTF-8 without control characters")
|
|
339
|
+
_check_ids(user_id=user_id, anon_id=anon_id, recommendation_id=recommendation_id)
|
|
340
|
+
t = _now_ms()
|
|
341
|
+
attributed = recommendation_id
|
|
342
|
+
key = self._key(user_id, anon_id)
|
|
343
|
+
if key is not None:
|
|
344
|
+
with self._lock_for(key):
|
|
345
|
+
state = self._load(key)
|
|
346
|
+
state.history.apply(item_id, level, t)
|
|
347
|
+
entry = state.shown.get(item_id)
|
|
348
|
+
# An explicit id of the recommendation that showed the item
|
|
349
|
+
# is still the click on it; another id leaves the entry alone.
|
|
350
|
+
if recommendation_id is None or (entry and entry[0] == recommendation_id):
|
|
351
|
+
attributed = _attribute(entry, level, t) or recommendation_id
|
|
352
|
+
self._save(key, state, t)
|
|
353
|
+
self._enqueue(
|
|
354
|
+
_compact(
|
|
355
|
+
{
|
|
356
|
+
"item_id": item_id,
|
|
357
|
+
"level": level,
|
|
358
|
+
"user_id": user_id,
|
|
359
|
+
"anon_id": anon_id,
|
|
360
|
+
"recommendation_id": attributed,
|
|
361
|
+
"timestamp": _iso(t),
|
|
362
|
+
"event_id": str(uuid.uuid4()),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def flush(self) -> None:
|
|
368
|
+
"""Send every queued event now, with retries (end of a job or serverless handler).
|
|
369
|
+
|
|
370
|
+
Never raises: failures are logged, and retryable batches stay queued.
|
|
371
|
+
"""
|
|
372
|
+
self._drain(retry=True)
|
|
373
|
+
|
|
374
|
+
def close(self) -> None:
|
|
375
|
+
"""Stop the sender and send what is queued, one try per batch (runs at exit).
|
|
376
|
+
|
|
377
|
+
No retries, so a deploy never waits on a recapi outage.
|
|
378
|
+
"""
|
|
379
|
+
with self._cond:
|
|
380
|
+
closing = not self._closed.is_set()
|
|
381
|
+
# Also ends a send's retry sleep, so close() waits for at most one try.
|
|
382
|
+
self._closed.set()
|
|
383
|
+
self._cond.notify_all()
|
|
384
|
+
if closing:
|
|
385
|
+
self._drain(retry=False)
|
|
386
|
+
# Also on a later close() (the one at exit): calls made after the
|
|
387
|
+
# first one may have opened connections again.
|
|
388
|
+
with self._idle_lock:
|
|
389
|
+
idle, self._idle = self._idle, []
|
|
390
|
+
for conn in idle:
|
|
391
|
+
conn.close()
|
|
392
|
+
|
|
393
|
+
def _enqueue(self, event: dict[str, Any]) -> None:
|
|
394
|
+
with self._cond:
|
|
395
|
+
if self._thread is None and not self._closed.is_set():
|
|
396
|
+
self._thread = threading.Thread(target=self._run, name="recapi-sender", daemon=True)
|
|
397
|
+
self._thread.start()
|
|
398
|
+
self._queue.append(event)
|
|
399
|
+
if len(self._queue) > MAX_QUEUE:
|
|
400
|
+
self._drop_overflow()
|
|
401
|
+
if len(self._queue) >= self._max_batch:
|
|
402
|
+
self._cond.notify_all()
|
|
403
|
+
if self._closed.is_set():
|
|
404
|
+
self._drain(retry=False)
|
|
405
|
+
|
|
406
|
+
def _drop_overflow(self) -> None:
|
|
407
|
+
dropped = len(self._queue) - MAX_QUEUE
|
|
408
|
+
del self._queue[:dropped]
|
|
409
|
+
log.warning("recapi: event queue full, dropped the %d oldest events", dropped)
|
|
410
|
+
|
|
411
|
+
def _run(self) -> None:
|
|
412
|
+
while True:
|
|
413
|
+
with self._cond:
|
|
414
|
+
delay = min(self._interval * 2**self._failures, MAX_FLUSH_BACKOFF)
|
|
415
|
+
# A full batch goes at once, except after a failure: then
|
|
416
|
+
# the backoff holds, or a down API would be hammered.
|
|
417
|
+
self._cond.wait_for(
|
|
418
|
+
lambda: (
|
|
419
|
+
self._closed.is_set()
|
|
420
|
+
or (self._failures == 0 and len(self._queue) >= self._max_batch)
|
|
421
|
+
),
|
|
422
|
+
timeout=delay,
|
|
423
|
+
)
|
|
424
|
+
if self._closed.is_set():
|
|
425
|
+
return
|
|
426
|
+
self._drain(retry=True)
|
|
427
|
+
|
|
428
|
+
def _drain(self, *, retry: bool) -> None:
|
|
429
|
+
with self._send_lock:
|
|
430
|
+
while True:
|
|
431
|
+
with self._cond:
|
|
432
|
+
batch = self._queue[: self._max_batch]
|
|
433
|
+
del self._queue[: len(batch)]
|
|
434
|
+
if not batch:
|
|
435
|
+
return
|
|
436
|
+
try:
|
|
437
|
+
self._send_events(batch, retry=retry)
|
|
438
|
+
except Exception as error:
|
|
439
|
+
if isinstance(error, RecapiError) and not error.retryable:
|
|
440
|
+
log.error("recapi: dropped %d events: %s", len(batch), error)
|
|
441
|
+
continue
|
|
442
|
+
# Anything else keeps the batch, a bug of ours included: event ids
|
|
443
|
+
# make a resend harmless, and a dead sender would lose every event.
|
|
444
|
+
if not isinstance(error, RecapiError):
|
|
445
|
+
log.exception("recapi: unexpected error sending events")
|
|
446
|
+
with self._cond:
|
|
447
|
+
self._queue[:0] = batch
|
|
448
|
+
if len(self._queue) > MAX_QUEUE:
|
|
449
|
+
self._drop_overflow()
|
|
450
|
+
self._failures += 1
|
|
451
|
+
log.warning("recapi: sending %d events failed, kept: %s", len(batch), error)
|
|
452
|
+
return
|
|
453
|
+
self._failures = 0
|
|
454
|
+
|
|
455
|
+
def _send_events(self, events: list[dict[str, Any]], *, retry: bool) -> None:
|
|
456
|
+
def send() -> Any:
|
|
457
|
+
return self._request("POST", "/v1/events", body={"events": events})
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
response = _with_retry(send, self._closed) if retry else send()
|
|
461
|
+
except RecapiError as error:
|
|
462
|
+
# Over the API's body limit (ids mostly quotes or backslashes, which
|
|
463
|
+
# JSON escapes): halves fit where the whole didn't. If the second
|
|
464
|
+
# half fails, the batch is kept whole; event ids make the resend harmless.
|
|
465
|
+
if error.status != 413 or len(events) < 2:
|
|
466
|
+
raise
|
|
467
|
+
half = len(events) // 2
|
|
468
|
+
self._send_events(events[:half], retry=retry)
|
|
469
|
+
self._send_events(events[half:], retry=retry)
|
|
470
|
+
return
|
|
471
|
+
rejected = response.get("rejected") if isinstance(response, dict) else None
|
|
472
|
+
if isinstance(rejected, list) and rejected:
|
|
473
|
+
# Invalid events are reported, never retried: they would fail again.
|
|
474
|
+
try:
|
|
475
|
+
detail = [{**r, "event": events[r["index"]]} for r in rejected[:5]]
|
|
476
|
+
except (TypeError, KeyError, IndexError):
|
|
477
|
+
detail = rejected[:5]
|
|
478
|
+
log.error("recapi: %d event(s) rejected: %s", len(rejected), detail)
|
|
479
|
+
|
|
480
|
+
# --- recommendations ----------------------------------------------------
|
|
481
|
+
|
|
482
|
+
def recommend(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
user_id: str | None = None,
|
|
486
|
+
anon_id: str | None = None,
|
|
487
|
+
count: int = 0,
|
|
488
|
+
placement: str | None = None,
|
|
489
|
+
tags_any: Tags = None,
|
|
490
|
+
tags_all: Tags = None,
|
|
491
|
+
exclude: Tags = None,
|
|
492
|
+
context_item_id: str | None = None,
|
|
493
|
+
discovery: float | None = None,
|
|
494
|
+
) -> Recommendations:
|
|
495
|
+
"""Personalised recommendations for a user (or a visitor's ``anon_id``).
|
|
496
|
+
|
|
497
|
+
Sends the history from the store; without one, recapi uses its stored
|
|
498
|
+
history for ``user_id`` and returns it for the store. A history the
|
|
499
|
+
store started without it (a ``track()`` or ``identify()`` came first)
|
|
500
|
+
gets recapi's fetched once, within the same deadline, and merged in.
|
|
501
|
+
No retry, at most 1.5 s: on a network error, timeout or 5xx it returns
|
|
502
|
+
popular items (``fallback=True``, no id) and logs the error. If those
|
|
503
|
+
fail too (recapi is down), the list is empty: hide the slot. A 4xx or
|
|
504
|
+
429 raises: a fallback would hide an integration bug or a plan limit.
|
|
505
|
+
"""
|
|
506
|
+
return self._recommend(
|
|
507
|
+
time.monotonic() + self._recommend_timeout,
|
|
508
|
+
user_id=user_id,
|
|
509
|
+
anon_id=anon_id,
|
|
510
|
+
count=count,
|
|
511
|
+
placement=placement,
|
|
512
|
+
tags_any=tags_any,
|
|
513
|
+
tags_all=tags_all,
|
|
514
|
+
exclude=exclude,
|
|
515
|
+
context_item_id=context_item_id,
|
|
516
|
+
discovery=discovery,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def _recommend_timeout(self) -> float:
|
|
521
|
+
return min(self._timeout, RECOMMEND_TIMEOUT)
|
|
522
|
+
|
|
523
|
+
def _recommend(
|
|
524
|
+
self,
|
|
525
|
+
deadline: float,
|
|
526
|
+
*,
|
|
527
|
+
user_id: str | None = None,
|
|
528
|
+
anon_id: str | None = None,
|
|
529
|
+
count: int = 0,
|
|
530
|
+
placement: str | None = None,
|
|
531
|
+
tags_any: Tags = None,
|
|
532
|
+
tags_all: Tags = None,
|
|
533
|
+
exclude: Tags = None,
|
|
534
|
+
context_item_id: str | None = None,
|
|
535
|
+
discovery: float | None = None,
|
|
536
|
+
) -> Recommendations:
|
|
537
|
+
"""recommend() with its deadline as a ``time.monotonic()`` instant.
|
|
538
|
+
|
|
539
|
+
AsyncClient sets it at the ``await``: a call that waited for a thread
|
|
540
|
+
past it goes straight to the fallback.
|
|
541
|
+
"""
|
|
542
|
+
key = self._key(user_id, anon_id)
|
|
543
|
+
t = _now_ms()
|
|
544
|
+
state = self._load(key) if key is not None else _UserState()
|
|
545
|
+
if (
|
|
546
|
+
user_id
|
|
547
|
+
and not state.restored
|
|
548
|
+
and not state.history.is_empty()
|
|
549
|
+
and not self._api_key.startswith(PUBLIC_KEY_PREFIX)
|
|
550
|
+
):
|
|
551
|
+
state = self._restore(key, user_id, state, deadline, t)
|
|
552
|
+
history, recent, rejected = state.history.wire(t)
|
|
553
|
+
try:
|
|
554
|
+
response = self._request(
|
|
555
|
+
"POST",
|
|
556
|
+
"/v1/recommendations",
|
|
557
|
+
timeout=deadline - time.monotonic(),
|
|
558
|
+
body=_compact(
|
|
559
|
+
{
|
|
560
|
+
"user_id": user_id,
|
|
561
|
+
"anon_id": anon_id,
|
|
562
|
+
"placement": placement,
|
|
563
|
+
"count": count or None,
|
|
564
|
+
"tags_any": _list(tags_any),
|
|
565
|
+
"tags_all": _list(tags_all),
|
|
566
|
+
"exclude": _with_rejected(exclude, rejected),
|
|
567
|
+
"context_item_id": context_item_id,
|
|
568
|
+
"history": history,
|
|
569
|
+
"recent": recent,
|
|
570
|
+
"discovery": discovery,
|
|
571
|
+
}
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
rec_id, items = _parse_recommendations(response)
|
|
575
|
+
except RecapiError as error:
|
|
576
|
+
if not error.outage:
|
|
577
|
+
raise
|
|
578
|
+
# The requested length without the excluded items, so a few more
|
|
579
|
+
# are asked for. 0 is the recommendations default; popular refuses it.
|
|
580
|
+
n = count or DEFAULT_COUNT
|
|
581
|
+
skip = set(_with_rejected(exclude, rejected) or ())
|
|
582
|
+
if context_item_id is not None:
|
|
583
|
+
skip.add(context_item_id)
|
|
584
|
+
try:
|
|
585
|
+
popular = self._popular(
|
|
586
|
+
min(n + len(skip), MAX_COUNT), tags_any, tags_all, self._recommend_timeout
|
|
587
|
+
)
|
|
588
|
+
except RecapiError as fallback_error:
|
|
589
|
+
# recapi is down altogether. The README promises no
|
|
590
|
+
# try/except, so an empty list the page can hide beats an
|
|
591
|
+
# exception that fails the whole page.
|
|
592
|
+
log.warning(
|
|
593
|
+
"recapi: recommendations failed (%s), popular too, served no items: %s",
|
|
594
|
+
error,
|
|
595
|
+
fallback_error,
|
|
596
|
+
)
|
|
597
|
+
return Recommendations(None, True, [])
|
|
598
|
+
log.warning("recapi: recommendations failed, served popular items: %s", error)
|
|
599
|
+
items = [item for item in popular if item.item_id not in skip][:n]
|
|
600
|
+
return Recommendations(None, True, items)
|
|
601
|
+
|
|
602
|
+
if key is not None:
|
|
603
|
+
with self._lock_for(key):
|
|
604
|
+
state = self._load(key)
|
|
605
|
+
for item in items:
|
|
606
|
+
state.shown.pop(item.item_id, None)
|
|
607
|
+
state.shown[item.item_id] = [rec_id, t]
|
|
608
|
+
while len(state.shown) > MAX_SHOWN_ITEMS:
|
|
609
|
+
del state.shown[next(iter(state.shown))]
|
|
610
|
+
# Only trusted when we sent nothing; merged, never replacing
|
|
611
|
+
# what was tracked while the request was in flight, and only
|
|
612
|
+
# once: a concurrent first call may have merged it already.
|
|
613
|
+
snapshot = response.get("history")
|
|
614
|
+
done = state.restored and not state.history.is_empty()
|
|
615
|
+
if not history and not recent and isinstance(snapshot, dict) and not done:
|
|
616
|
+
state.history = state.history.merge_snapshot(History.from_snapshot(snapshot), t)
|
|
617
|
+
state.restored = True
|
|
618
|
+
self._save(key, state, t)
|
|
619
|
+
return Recommendations(rec_id, False, items)
|
|
620
|
+
|
|
621
|
+
def _restore(
|
|
622
|
+
self, key: str, user_id: str, state: _UserState, deadline: float, t: int
|
|
623
|
+
) -> _UserState:
|
|
624
|
+
"""Merge recapi's stored history into a local one that lacks it (a fresh store).
|
|
625
|
+
|
|
626
|
+
The recommendation's reply carries it only when the request sends no
|
|
627
|
+
history, which would drop this one. So it is asked for apart, once:
|
|
628
|
+
a failure leaves the state as it was, to be tried on the next call.
|
|
629
|
+
"""
|
|
630
|
+
path = "/v1/users/" + urllib.parse.quote(user_id, safe="") + "/history"
|
|
631
|
+
try:
|
|
632
|
+
# Half the time left, so the recommendation itself still fits.
|
|
633
|
+
snapshot = self._request("GET", path, timeout=(deadline - time.monotonic()) / 2)
|
|
634
|
+
if not isinstance(snapshot, dict):
|
|
635
|
+
raise RecapiError("invalid_response", "malformed history response")
|
|
636
|
+
except RecapiError as error:
|
|
637
|
+
log.warning("recapi: could not restore the user's stored history: %s", error)
|
|
638
|
+
return state
|
|
639
|
+
with self._lock_for(key):
|
|
640
|
+
state = self._load(key)
|
|
641
|
+
# A concurrent first call may have merged it already.
|
|
642
|
+
if not state.restored:
|
|
643
|
+
state.history = state.history.merge_snapshot(History.from_snapshot(snapshot), t)
|
|
644
|
+
state.restored = True
|
|
645
|
+
self._save(key, state, t)
|
|
646
|
+
return state
|
|
647
|
+
|
|
648
|
+
def identify(self, anon_id: str, user_id: str) -> None:
|
|
649
|
+
"""Call at login: the visitor's history and shown items become the user's.
|
|
650
|
+
|
|
651
|
+
Merged into what the store has for ``user_id`` (scores add up, as in
|
|
652
|
+
the history spec), then the visitor's entry is deleted. Without it, a
|
|
653
|
+
visitor's clicks stop counting once they log in.
|
|
654
|
+
"""
|
|
655
|
+
_check_ids(anon_id=anon_id, user_id=user_id)
|
|
656
|
+
if not anon_id or not user_id:
|
|
657
|
+
raise ValueError("anon_id and user_id are required")
|
|
658
|
+
visitor, user = self._key(None, anon_id), self._key(user_id, None)
|
|
659
|
+
assert visitor is not None and user is not None
|
|
660
|
+
t = _now_ms()
|
|
661
|
+
with self._locks_for(visitor, user):
|
|
662
|
+
guest = self._load(visitor)
|
|
663
|
+
if guest.history.is_empty() and not guest.history.rejected and not guest.shown:
|
|
664
|
+
return
|
|
665
|
+
state = self._load(user)
|
|
666
|
+
state.history = state.history.merge(guest.history, t)
|
|
667
|
+
# Oldest display first, as recommend() keeps them; an item's later display wins.
|
|
668
|
+
shown = sorted([*state.shown.items(), *guest.shown.items()], key=lambda e: e[1][1])
|
|
669
|
+
state.shown = {}
|
|
670
|
+
for item, entry in shown:
|
|
671
|
+
state.shown.pop(item, None)
|
|
672
|
+
state.shown[item] = entry
|
|
673
|
+
self._save(user, state, t)
|
|
674
|
+
try:
|
|
675
|
+
self._store.delete(visitor)
|
|
676
|
+
except Exception:
|
|
677
|
+
log.exception("recapi: could not delete the visitor's stored state")
|
|
678
|
+
|
|
679
|
+
def popular(
|
|
680
|
+
self,
|
|
681
|
+
*,
|
|
682
|
+
count: int = DEFAULT_COUNT,
|
|
683
|
+
tags_any: Tags = None,
|
|
684
|
+
tags_all: Tags = None,
|
|
685
|
+
) -> list[Item]:
|
|
686
|
+
"""Non-personalised popular items, for pages without a user (no retry)."""
|
|
687
|
+
return self._popular(count, tags_any, tags_all, None)
|
|
688
|
+
|
|
689
|
+
def _popular(self, count: int, tags_any: Tags, tags_all: Tags, timeout: float | None) -> list:
|
|
690
|
+
query = {k: ",".join(v) for k, v in (("tags_any", tags_any), ("tags_all", tags_all)) if v}
|
|
691
|
+
query["count"] = str(count)
|
|
692
|
+
response = self._request("GET", "/v1/popular", timeout=timeout, query=query)
|
|
693
|
+
raw = response.get("items") if isinstance(response, dict) else None
|
|
694
|
+
if not isinstance(raw, list):
|
|
695
|
+
raise RecapiError("invalid_response", "malformed popular response")
|
|
696
|
+
return [_item(entry, "backfill") for entry in raw]
|
|
697
|
+
|
|
698
|
+
# --- catalog and users --------------------------------------------------
|
|
699
|
+
|
|
700
|
+
def upsert_items(self, items: Iterable[Mapping[str, Any]]) -> int:
|
|
701
|
+
"""Create or replace items (dicts of the API's item fields), 1,000 per request.
|
|
702
|
+
|
|
703
|
+
Retried like events. Returns how many were stored; an invalid item
|
|
704
|
+
raises, and nothing of its batch of 1,000 is stored.
|
|
705
|
+
"""
|
|
706
|
+
batch: list[dict[str, Any]] = []
|
|
707
|
+
upserted = 0
|
|
708
|
+
|
|
709
|
+
def send() -> int:
|
|
710
|
+
response = _with_retry(
|
|
711
|
+
lambda: self._request("POST", "/v1/items:batch", body={"items": batch})
|
|
712
|
+
)
|
|
713
|
+
return int(response.get("upserted", 0)) if isinstance(response, dict) else 0
|
|
714
|
+
|
|
715
|
+
for item in items:
|
|
716
|
+
batch.append(dict(item))
|
|
717
|
+
if len(batch) == MAX_ITEMS_PER_REQUEST:
|
|
718
|
+
upserted += send()
|
|
719
|
+
batch = []
|
|
720
|
+
if batch:
|
|
721
|
+
upserted += send()
|
|
722
|
+
return upserted
|
|
723
|
+
|
|
724
|
+
def delete_user(self, user_id: str) -> None:
|
|
725
|
+
"""Erase a user's events in recapi (GDPR) and their state in the store."""
|
|
726
|
+
if not is_id(user_id):
|
|
727
|
+
raise ValueError("user_id must be 1-256 bytes of UTF-8 without control characters")
|
|
728
|
+
path = "/v1/users/" + urllib.parse.quote(user_id, safe="")
|
|
729
|
+
_with_retry(lambda: self._request("DELETE", path))
|
|
730
|
+
try:
|
|
731
|
+
self._store.delete(self._key(user_id, None))
|
|
732
|
+
except Exception:
|
|
733
|
+
log.exception("recapi: could not delete the user's stored state")
|
|
734
|
+
|
|
735
|
+
# --- plumbing -----------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
def _key(self, user_id: str | None, anon_id: str | None) -> str | None:
|
|
738
|
+
if user_id:
|
|
739
|
+
return self._prefix + "u:" + user_id
|
|
740
|
+
if anon_id:
|
|
741
|
+
return self._prefix + "a:" + anon_id
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
def _lock_for(self, key: str) -> threading.Lock:
|
|
745
|
+
return self._user_locks[hash(key) % len(self._user_locks)]
|
|
746
|
+
|
|
747
|
+
@contextlib.contextmanager
|
|
748
|
+
def _locks_for(self, *keys: str) -> Any:
|
|
749
|
+
# Always in the same order, or two logins could each hold the other's lock.
|
|
750
|
+
indexes = sorted({hash(key) % len(self._user_locks) for key in keys})
|
|
751
|
+
with contextlib.ExitStack() as stack:
|
|
752
|
+
for i in indexes:
|
|
753
|
+
stack.enter_context(self._user_locks[i])
|
|
754
|
+
yield
|
|
755
|
+
|
|
756
|
+
def _load(self, key: str) -> _UserState:
|
|
757
|
+
# The store is a cache: when it fails, recapi's stored history is
|
|
758
|
+
# still there, so the call goes on without it.
|
|
759
|
+
try:
|
|
760
|
+
raw = self._store.get(key)
|
|
761
|
+
except Exception:
|
|
762
|
+
log.exception("recapi: store read failed")
|
|
763
|
+
return _UserState()
|
|
764
|
+
return _UserState.loads(raw)
|
|
765
|
+
|
|
766
|
+
def _save(self, key: str, state: _UserState, now: int) -> None:
|
|
767
|
+
try:
|
|
768
|
+
self._store.set(key, state.dumps(now))
|
|
769
|
+
except Exception:
|
|
770
|
+
log.exception("recapi: store write failed")
|
|
771
|
+
|
|
772
|
+
def _request(
|
|
773
|
+
self,
|
|
774
|
+
method: str,
|
|
775
|
+
path: str,
|
|
776
|
+
*,
|
|
777
|
+
body: object = None,
|
|
778
|
+
query: Mapping[str, object] | None = None,
|
|
779
|
+
timeout: float | None = None,
|
|
780
|
+
) -> Any:
|
|
781
|
+
params = {k: str(v) for k, v in (query or {}).items() if v is not None}
|
|
782
|
+
headers = {"Accept": "application/json", "User-Agent": f"recapi-python/{__version__}"}
|
|
783
|
+
# A public key travels in the URL only on GET /v1/popular (CDN
|
|
784
|
+
# caching); the API refuses a secret key there.
|
|
785
|
+
if method == "GET" and self._api_key.startswith(PUBLIC_KEY_PREFIX):
|
|
786
|
+
params["key"] = self._api_key
|
|
787
|
+
else:
|
|
788
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
789
|
+
if self._target_base == self._base:
|
|
790
|
+
headers.update(self._proxy_auth)
|
|
791
|
+
target = self._target_base + path
|
|
792
|
+
if params:
|
|
793
|
+
target += "?" + urllib.parse.urlencode(params)
|
|
794
|
+
data = None
|
|
795
|
+
if body is not None:
|
|
796
|
+
# Raw UTF-8: escaped, non-Latin ids would take up to 3 times the
|
|
797
|
+
# bytes, and a batch of 1,000 could pass the API's 2 MiB limit.
|
|
798
|
+
data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
799
|
+
headers["Content-Type"] = "application/json"
|
|
800
|
+
limit = self._timeout if timeout is None else timeout
|
|
801
|
+
if limit <= 0:
|
|
802
|
+
raise RecapiError("timeout", "the deadline passed before the request was sent")
|
|
803
|
+
try:
|
|
804
|
+
status, reply_headers, text = self._exchange(method, target, data, headers, limit)
|
|
805
|
+
except TimeoutError as error:
|
|
806
|
+
raise RecapiError("timeout", f"request timed out after {limit:.3g} s") from error
|
|
807
|
+
except (OSError, http.client.HTTPException) as error:
|
|
808
|
+
# HTTPException: a reply cut mid-body (IncompleteRead) or garbled
|
|
809
|
+
# by a proxy, which the JS SDK gets as a failed response.text().
|
|
810
|
+
raise RecapiError("network", str(error) or type(error).__name__) from error
|
|
811
|
+
# http.client follows no redirect: a 3xx (an http:// base_url sent on
|
|
812
|
+
# to https) fails like a 4xx, or a DELETE would look done.
|
|
813
|
+
if not 200 <= status < 300:
|
|
814
|
+
raise _http_error(status, reply_headers, text)
|
|
815
|
+
return _parse_json(text)
|
|
816
|
+
|
|
817
|
+
def _exchange(
|
|
818
|
+
self, method: str, target: str, data: bytes | None, headers: dict[str, str], limit: float
|
|
819
|
+
) -> tuple[int, Any, bytes]:
|
|
820
|
+
"""Send one request on an idle connection, or a new one; return status, headers and body.
|
|
821
|
+
|
|
822
|
+
A connection the server closed while idle fails before any reply
|
|
823
|
+
comes: it is replaced once. A failure once the reply has started is
|
|
824
|
+
never resent: the API has the request (a recommendation would count twice).
|
|
825
|
+
"""
|
|
826
|
+
start = time.monotonic()
|
|
827
|
+
with self._idle_lock:
|
|
828
|
+
# The most recently used: the least likely to have timed out.
|
|
829
|
+
conn = self._idle.pop() if self._idle else None
|
|
830
|
+
for attempt in range(2):
|
|
831
|
+
if conn is None:
|
|
832
|
+
conn = self._connect()
|
|
833
|
+
reused = conn.sock is not None
|
|
834
|
+
remaining = limit - (time.monotonic() - start)
|
|
835
|
+
if remaining <= 0: # settimeout() takes 0 as "never block"
|
|
836
|
+
self._keep(conn)
|
|
837
|
+
raise TimeoutError
|
|
838
|
+
conn.timeout = remaining
|
|
839
|
+
if conn.sock is not None:
|
|
840
|
+
conn.sock.settimeout(remaining)
|
|
841
|
+
try:
|
|
842
|
+
conn.request(method, target, body=data, headers=headers)
|
|
843
|
+
response = conn.getresponse()
|
|
844
|
+
except ConnectionError:
|
|
845
|
+
conn.close()
|
|
846
|
+
if not reused or attempt:
|
|
847
|
+
raise
|
|
848
|
+
conn = None
|
|
849
|
+
continue
|
|
850
|
+
except BaseException:
|
|
851
|
+
conn.close()
|
|
852
|
+
raise
|
|
853
|
+
try:
|
|
854
|
+
body = response.read()
|
|
855
|
+
except BaseException:
|
|
856
|
+
# Half a reply may be left on the socket: never reuse it.
|
|
857
|
+
conn.close()
|
|
858
|
+
raise
|
|
859
|
+
self._keep(conn)
|
|
860
|
+
return response.status, response.headers, body
|
|
861
|
+
raise AssertionError("unreachable")
|
|
862
|
+
|
|
863
|
+
def _keep(self, conn: http.client.HTTPConnection) -> None:
|
|
864
|
+
"""Return a connection whose last reply was read whole to the idle ones."""
|
|
865
|
+
if conn.sock is None: # the server said "Connection: close"
|
|
866
|
+
return
|
|
867
|
+
with self._idle_lock:
|
|
868
|
+
if len(self._idle) < MAX_IDLE_CONNECTIONS:
|
|
869
|
+
self._idle.append(conn)
|
|
870
|
+
return
|
|
871
|
+
conn.close()
|
|
872
|
+
|
|
873
|
+
def _connect(self) -> http.client.HTTPConnection:
|
|
874
|
+
https = self._url.scheme == "https"
|
|
875
|
+
kind = http.client.HTTPSConnection if https else http.client.HTTPConnection
|
|
876
|
+
if self._proxy is None:
|
|
877
|
+
return kind(self._url.hostname, self._url.port, timeout=self._timeout)
|
|
878
|
+
conn = kind(self._proxy.hostname, self._proxy.port or 80, timeout=self._timeout)
|
|
879
|
+
if https:
|
|
880
|
+
conn.set_tunnel(self._url.hostname, self._url.port or 443, headers=self._proxy_auth)
|
|
881
|
+
return conn
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _call(ref: weakref.ref, method: str) -> None:
|
|
885
|
+
client = ref()
|
|
886
|
+
if client is not None:
|
|
887
|
+
getattr(client, method)()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _basic_auth(proxy: urllib.parse.SplitResult | None) -> dict[str, str]:
|
|
891
|
+
if proxy is None or proxy.username is None:
|
|
892
|
+
return {}
|
|
893
|
+
user = urllib.parse.unquote(proxy.username) + ":" + urllib.parse.unquote(proxy.password or "")
|
|
894
|
+
return {"Proxy-Authorization": "Basic " + base64.b64encode(user.encode()).decode()}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _http_error(status: int, headers: Any, text: bytes) -> RecapiError:
|
|
898
|
+
payload = _parse_json(text)
|
|
899
|
+
body = payload.get("error") if isinstance(payload, dict) else None
|
|
900
|
+
body = body if isinstance(body, dict) else {}
|
|
901
|
+
code = (
|
|
902
|
+
body.get("code")
|
|
903
|
+
or _CODE_BY_STATUS.get(status)
|
|
904
|
+
or ("internal" if status >= 500 else "http_error")
|
|
905
|
+
)
|
|
906
|
+
return RecapiError(
|
|
907
|
+
str(code),
|
|
908
|
+
str(body.get("message") or f"HTTP {status}"),
|
|
909
|
+
status=status,
|
|
910
|
+
retry_after=_retry_after(headers.get("Retry-After")),
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _retry_after(header: str | None) -> float | None:
|
|
915
|
+
try:
|
|
916
|
+
seconds = float(header or "")
|
|
917
|
+
except ValueError:
|
|
918
|
+
return None
|
|
919
|
+
return seconds if 0 <= seconds < float("inf") else None
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _parse_json(raw: bytes) -> Any:
|
|
923
|
+
if not raw:
|
|
924
|
+
return None
|
|
925
|
+
try:
|
|
926
|
+
return json.loads(raw)
|
|
927
|
+
except ValueError:
|
|
928
|
+
return None
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _with_retry(fn: Any, stop: threading.Event | None = None) -> Any:
|
|
932
|
+
"""Call ``fn``, retrying retryable errors with jittered exponential backoff.
|
|
933
|
+
|
|
934
|
+
``Retry-After`` wins over the backoff, and both are capped at 8 s. Once
|
|
935
|
+
``stop`` is set (the client closing), the last error is raised instead.
|
|
936
|
+
"""
|
|
937
|
+
for attempt in range(RETRIES + 1):
|
|
938
|
+
try:
|
|
939
|
+
return fn()
|
|
940
|
+
except RecapiError as error:
|
|
941
|
+
if attempt >= RETRIES or not error.retryable:
|
|
942
|
+
raise
|
|
943
|
+
backoff = RETRY_BASE * 2**attempt * (0.5 + _random() / 2)
|
|
944
|
+
wait = error.retry_after if error.retry_after is not None else backoff
|
|
945
|
+
if _sleep(min(RETRY_MAX, wait), stop):
|
|
946
|
+
raise
|
|
947
|
+
raise AssertionError("unreachable")
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _parse_recommendations(response: Any) -> tuple[str, list[Item]]:
|
|
951
|
+
if (
|
|
952
|
+
not isinstance(response, dict)
|
|
953
|
+
or not isinstance(response.get("recommendation_id"), str)
|
|
954
|
+
or not isinstance(response.get("items"), list)
|
|
955
|
+
):
|
|
956
|
+
raise RecapiError("invalid_response", "malformed recommendations response")
|
|
957
|
+
return response["recommendation_id"], [_item(e, "exploit") for e in response["items"]]
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _item(entry: Any, slot: str) -> Item:
|
|
961
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("item_id"), str):
|
|
962
|
+
raise RecapiError("invalid_response", "malformed item in response")
|
|
963
|
+
metadata = entry.get("metadata")
|
|
964
|
+
return Item(
|
|
965
|
+
entry["item_id"],
|
|
966
|
+
float(entry.get("score") or 0),
|
|
967
|
+
str(entry.get("slot") or slot),
|
|
968
|
+
metadata if isinstance(metadata, dict) and metadata else None,
|
|
969
|
+
why if isinstance(why := entry.get("why"), str) and why else None,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _attribute(entry: list[Any] | None, level: int, t: int) -> str | None:
|
|
974
|
+
"""Return the recommendation an event at ``t`` belongs to; a first click is recorded."""
|
|
975
|
+
if entry is None or not _attributable(entry, t):
|
|
976
|
+
return None
|
|
977
|
+
if level > 0 and len(entry) < 3:
|
|
978
|
+
entry.append(t)
|
|
979
|
+
return entry[0]
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _with_rejected(exclude: Sequence[str] | None, rejected: list[str]) -> list[str] | None:
|
|
983
|
+
"""Return the caller's exclude plus the rejected items, newest first, up to the API limit."""
|
|
984
|
+
if not rejected:
|
|
985
|
+
return list(exclude) if exclude is not None else None
|
|
986
|
+
out = dict.fromkeys(exclude or ())
|
|
987
|
+
for item in reversed(rejected):
|
|
988
|
+
if len(out) >= MAX_EXCLUDE:
|
|
989
|
+
break
|
|
990
|
+
out.setdefault(item)
|
|
991
|
+
return list(out)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _level(level: object) -> int:
|
|
995
|
+
if isinstance(level, str) and level in LEVEL_NAMES:
|
|
996
|
+
return LEVEL_NAMES[level]
|
|
997
|
+
if isinstance(level, int) and not isinstance(level, bool) and -10 <= level <= 10:
|
|
998
|
+
return level
|
|
999
|
+
raise ValueError(f"level must be an integer from -10 to 10 or one of {list(LEVEL_NAMES)}")
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _check_ids(**ids: str | None) -> None:
|
|
1003
|
+
for name, value in ids.items():
|
|
1004
|
+
if value is not None and not is_id(value):
|
|
1005
|
+
raise ValueError(f"{name} must be 1-256 bytes of UTF-8 without control characters")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _list(values: Sequence[str] | None) -> list[str] | None:
|
|
1009
|
+
return list(values) if values else None
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _compact(fields: dict[str, Any]) -> dict[str, Any]:
|
|
1013
|
+
return {k: v for k, v in fields.items() if v is not None}
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _iso(t_ms: int) -> str:
|
|
1017
|
+
moment = datetime.datetime.fromtimestamp(t_ms / 1000, tz=datetime.UTC)
|
|
1018
|
+
return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|