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/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")