recapi 0.1.7 → 0.1.8
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.
- checksums.yaml +4 -4
- data/README.md +10 -6
- data/lib/recapi.rb +63 -19
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45a27aa9c5eae2fa2088d58247ebd703b60df834bc22e508737d89f17e90a8a2
|
|
4
|
+
data.tar.gz: 7443f75901c5e2600ea181f3a61277bc9b2e5b2820aad2c999dba4b8353521b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 856de23aa6d4936301ac4d9a2dbd2de72c4ffea40e44ed41459f7b310ff36dc232103f9e8e6cd11d265790e6b95eda80cea2d6c40087d68df252d2861c5f2956
|
|
7
|
+
data.tar.gz: dfd97019465b529c84fb4d315876acac76a414432fe3d53a901ec037f3b03c5490c2ce76ed2f3000651ff904f37115c9da9d046c59c99286924abcfea1ded8fc
|
data/README.md
CHANGED
|
@@ -30,7 +30,7 @@ line with your address): save it as `lib/recapi.rb` and
|
|
|
30
30
|
`require Rails.root.join("lib/recapi").to_s` instead.
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.
|
|
33
|
+
curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.8.rb
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Quick start
|
|
@@ -64,16 +64,19 @@ Ids may be strings or integers (an ActiveRecord id): they travel as strings.
|
|
|
64
64
|
|
|
65
65
|
A refused argument (an invalid id or level) raises `ArgumentError`, and an
|
|
66
66
|
API failure `Recapi::Error` (`code`, `status`, `retry_after`, `retryable?`,
|
|
67
|
-
`outage?`).
|
|
67
|
+
`outage?`). `recommend` and `popular` still raise on a bad request (400, 404,
|
|
68
|
+
413, other 3xx/4xx) or a reply that isn't the API's; an outage, a limit or a
|
|
69
|
+
refused key can start on a live site with no deploy, so they answer with a list
|
|
70
|
+
and a log line.
|
|
68
71
|
|
|
69
72
|
| Call | What it does |
|
|
70
73
|
|---|---|
|
|
71
74
|
| `track(item_id, level, user_id:, anon_id:, recommendation_id:)` | Records an interaction: `level` -10 to 10 or `:view` (3), `:cart` (5), `:purchase` (10), `:reject` (-10: never recommend the item to this user again until a positive event on it). The history changes at once; a background thread sends the event within 2 s (at once when a batch of 1,000 is full). |
|
|
72
75
|
| `flush` | Sends the queued events now, with retries (end of a job). A network error, 429 or 5xx is retried 3 times (0.5 s, 1 s, 2 s with jitter, or the `Retry-After`, at most 8 s), then the events stay queued and the sender backs off up to a minute; any other error drops them (logged). |
|
|
73
76
|
| `close` | Stops the sender and sends what is queued, one try per batch. It runs by itself at exit. |
|
|
74
|
-
| `recommend(user_id:, anon_id:, count:, placement:, tags_any:, tags_all:, exclude:, context_item_id:, discovery:)` | A `Recapi::Recommendations` (`recommendation_id`, `fallback?`, `items`). At most 1.5 s; on a network error, timeout or
|
|
77
|
+
| `recommend(user_id:, anon_id:, count:, placement:, tags_any:, tags_all:, exclude:, context_item_id:, discovery:)` | A `Recapi::Recommendations` (`recommendation_id`, `fallback?`, `items`). At most 1.5 s; on a network error, timeout, 5xx or 429 it returns popular items (`fallback?`, no id), or no item when those fail too: hide the slot; on a 401 or 403 no item at once. Any other failure (a bad request, a reply that isn't the API's) raises. |
|
|
75
78
|
| `identify(anon_id, user_id)` | At login: the visitor's history and shown items become the user's. The user's first restore from recapi then leaves that visitor's browsing out, so it doesn't count twice. |
|
|
76
|
-
| `popular(count:, tags_any:, tags_all:)` | Popular items, for pages without a user. |
|
|
79
|
+
| `popular(count:, tags_any:, tags_all:)` | Popular items, for pages without a user. On a network error, timeout, 5xx, 429, 401 or 403: an empty list and a log line. Other failures as in `recommend`. |
|
|
77
80
|
| `upsert_items(items)` | Creates or replaces items (hashes of `item_id`, `title`, `tags`, `metadata`…), 1,000 per request, retried like events. Returns how many were stored. |
|
|
78
81
|
| `delete_user(user_id)` | Erases a user's events in recapi and their state in the store, retried like events. |
|
|
79
82
|
|
|
@@ -95,8 +98,9 @@ queue and sender at its first call. Proxies come from `HTTPS_PROXY`,
|
|
|
95
98
|
to `logger:`, anything with `warn` and `error` (`Rails.logger`, a
|
|
96
99
|
`Logger`); without one they go to `$stderr` as `recapi: …` lines, with lost
|
|
97
100
|
events (a refused batch, rejected events) at most one line a minute for each
|
|
98
|
-
error code and for rejected events,
|
|
99
|
-
|
|
101
|
+
error code and for rejected events, and `recommend`'s and `popular`'s failures
|
|
102
|
+
at most one line a minute for each error code, so a revoked key on a busy server
|
|
103
|
+
doesn't flood the log, and a full queue one line until a send works. A failed write to
|
|
100
104
|
`$stderr` (a full disk, a closed stream) is ignored. A logger you give gets
|
|
101
105
|
every line, and if it raises, that reaches your code.
|
|
102
106
|
|
data/lib/recapi.rb
CHANGED
|
@@ -25,7 +25,7 @@ rescue LoadError
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
module Recapi
|
|
28
|
-
VERSION = "0.1.
|
|
28
|
+
VERSION = "0.1.8"
|
|
29
29
|
|
|
30
30
|
# Decayed user history, exactly as docs/history-spec.md (times in unix ms).
|
|
31
31
|
# A port of sdk/python's history.py, snapshot merging included.
|
|
@@ -297,8 +297,7 @@ module Recapi
|
|
|
297
297
|
@status >= 500
|
|
298
298
|
end
|
|
299
299
|
|
|
300
|
-
# Whether recapi couldn't answer in time or failed on its side.
|
|
301
|
-
# the tenant's own limit and a 4xx an integration bug: a fallback would hide both.
|
|
300
|
+
# Whether recapi couldn't answer in time or failed on its side.
|
|
302
301
|
def outage?
|
|
303
302
|
%w[network timeout].include?(@code) || @status >= 500
|
|
304
303
|
end
|
|
@@ -551,10 +550,12 @@ module Recapi
|
|
|
551
550
|
# for user_id and returns it for the store. A history the store started
|
|
552
551
|
# without it (a track() or identify() came first) gets recapi's fetched
|
|
553
552
|
# once, within the same deadline, and merged in. No retry, at most 1.5 s:
|
|
554
|
-
# on a network error, timeout or
|
|
555
|
-
# no id) and logs the error. If those fail too (recapi is down
|
|
556
|
-
# is empty: hide the slot.
|
|
557
|
-
#
|
|
553
|
+
# on a network error, timeout, 5xx or 429 it returns popular items (fallback,
|
|
554
|
+
# no id) and logs the error. If those fail too (recapi is down, or the plan's
|
|
555
|
+
# limit covers them), the list is empty: hide the slot. On a 401 or 403 (a
|
|
556
|
+
# revoked key, a suspended shop) it is empty at once, as popular would be
|
|
557
|
+
# refused too. Any other failure raises: a bad request or a reply that isn't
|
|
558
|
+
# the API's can't start on a live site without a deploy.
|
|
558
559
|
def recommend(user_id: nil, anon_id: nil, count: 0, placement: nil, tags_any: nil, tags_all: nil,
|
|
559
560
|
exclude: nil, context_item_id: nil, discovery: nil)
|
|
560
561
|
deadline = monotonic + recommend_timeout
|
|
@@ -582,7 +583,7 @@ module Recapi
|
|
|
582
583
|
)
|
|
583
584
|
rec_id, items = parse_recommendations(response)
|
|
584
585
|
rescue Error => e
|
|
585
|
-
raise unless e
|
|
586
|
+
raise unless spare_the_page?(e)
|
|
586
587
|
|
|
587
588
|
return fallback(e, count, excluded, context_item_id, tags_any, tags_all)
|
|
588
589
|
end
|
|
@@ -646,9 +647,16 @@ module Recapi
|
|
|
646
647
|
nil
|
|
647
648
|
end
|
|
648
649
|
|
|
649
|
-
# Non-personalised popular items, for pages without a user (no retry).
|
|
650
|
+
# Non-personalised popular items, for pages without a user (no retry). On a
|
|
651
|
+
# network error, timeout, 5xx, 429, 401 or 403 it logs the error and returns
|
|
652
|
+
# an empty list: hide the slot. Any other failure raises, as in recommend.
|
|
650
653
|
def popular(count: DEFAULT_COUNT, tags_any: nil, tags_all: nil)
|
|
651
654
|
fetch_popular(count, tags_any, tags_all, nil)
|
|
655
|
+
rescue Error => e
|
|
656
|
+
raise unless spare_the_page?(e)
|
|
657
|
+
|
|
658
|
+
log_list(e, "recapi: popular items failed, served no items: #{e.code}: #{e.message}")
|
|
659
|
+
[]
|
|
652
660
|
end
|
|
653
661
|
|
|
654
662
|
# Creates or replaces items (hashes of the API's item fields: item_id,
|
|
@@ -892,17 +900,32 @@ module Recapi
|
|
|
892
900
|
# A revoked key refuses every batch and the sender runs on with no backoff,
|
|
893
901
|
# so $stderr gets one line a minute per kind (error code, or rejected
|
|
894
902
|
# events): one line per outage would have aged out of the log by the time
|
|
895
|
-
# someone looks. A logger given gets them all.
|
|
903
|
+
# someone looks. A logger given gets them all.
|
|
896
904
|
def log_lost(kind, message)
|
|
897
|
-
if
|
|
898
|
-
|
|
905
|
+
@logger.error(message) if line_due?(kind)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# A list call's failure, one per page view through a revoked key or an
|
|
909
|
+
# outage: limited like lost events on $stderr, one line a minute per code
|
|
910
|
+
# whichever call hit it. Its own kind, or a list line would mute the line
|
|
911
|
+
# saying events are being lost.
|
|
912
|
+
def log_list(error, message)
|
|
913
|
+
@logger.warn(message) if line_due?("list #{error.code}")
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Any app thread can get here, next to the sender: not while holding @mutex.
|
|
917
|
+
def line_due?(kind)
|
|
918
|
+
return true unless @limit_lost_lines
|
|
919
|
+
|
|
920
|
+
now = @clock.call
|
|
921
|
+
@mutex.synchronize do
|
|
899
922
|
last = @lost_printed[kind]
|
|
900
923
|
# abs: a clock set back must not mute the kind until it catches up.
|
|
901
|
-
|
|
924
|
+
next false if last && (now - last).abs < LOST_LINE_MS
|
|
902
925
|
|
|
903
926
|
@lost_printed[kind] = now
|
|
927
|
+
true
|
|
904
928
|
end
|
|
905
|
-
@logger.error(message)
|
|
906
929
|
end
|
|
907
930
|
|
|
908
931
|
# Calls the block, retrying retryable errors with jittered exponential
|
|
@@ -925,7 +948,25 @@ module Recapi
|
|
|
925
948
|
|
|
926
949
|
# --- recommendations ------------------------------------------------------
|
|
927
950
|
|
|
951
|
+
# Whether recommend and popular answer this failure with a list instead of
|
|
952
|
+
# raising it. An outage, a limit or a revoked key can start on a live site
|
|
953
|
+
# with no deploy, and a page must not pay for it: the log and the dashboard
|
|
954
|
+
# show it. What's left (a bad request, a reply that isn't the API's) can't
|
|
955
|
+
# start without a deploy, and a developer sees it on the first run.
|
|
956
|
+
def spare_the_page?(error)
|
|
957
|
+
error.outage? || error.status == 429 || key_refused?(error)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# A key or shop recapi won't serve: any other call fails too.
|
|
961
|
+
def key_refused?(error)
|
|
962
|
+
[401, 403].include?(error.status)
|
|
963
|
+
end
|
|
964
|
+
|
|
928
965
|
def fallback(error, count, excluded, context_item_id, tags_any, tags_all)
|
|
966
|
+
if key_refused?(error)
|
|
967
|
+
log_list(error, "recapi: recommendations refused, served no items: #{error.code}: #{error.message}")
|
|
968
|
+
return Recommendations.new(recommendation_id: nil, fallback: true, items: [])
|
|
969
|
+
end
|
|
929
970
|
# The requested length without the excluded items, so a few more are
|
|
930
971
|
# asked for. 0 is the recommendations default; popular refuses it.
|
|
931
972
|
n = count.to_i.zero? ? DEFAULT_COUNT : count.to_i
|
|
@@ -934,12 +975,13 @@ module Recapi
|
|
|
934
975
|
begin
|
|
935
976
|
popular = fetch_popular([n + skip.size, MAX_COUNT].min, tags_any, tags_all, recommend_timeout)
|
|
936
977
|
rescue Error => e
|
|
937
|
-
# recapi is down
|
|
938
|
-
# exception that fails the whole page.
|
|
939
|
-
|
|
978
|
+
# recapi is down or the limit covers popular too: an empty list the page
|
|
979
|
+
# can hide beats an exception that fails the whole page.
|
|
980
|
+
log_list(error, "recapi: recommendations failed (#{error.code}: #{error.message}), popular too, " \
|
|
981
|
+
"served no items: #{e.code}: #{e.message}")
|
|
940
982
|
return Recommendations.new(recommendation_id: nil, fallback: true, items: [])
|
|
941
983
|
end
|
|
942
|
-
|
|
984
|
+
log_list(error, "recapi: recommendations failed, served popular items: #{error.code}: #{error.message}")
|
|
943
985
|
items = popular.reject { |item| skip.key?(item.item_id) }.first(n)
|
|
944
986
|
Recommendations.new(recommendation_id: nil, fallback: true, items: items)
|
|
945
987
|
end
|
|
@@ -956,7 +998,9 @@ module Recapi
|
|
|
956
998
|
snapshot = request("GET", path, query: query, timeout: (deadline - monotonic) / 2)
|
|
957
999
|
raise Error.new("invalid_response", "malformed history response") unless snapshot.is_a?(Hash)
|
|
958
1000
|
rescue Error => e
|
|
959
|
-
|
|
1001
|
+
# The same limit as the list's own line: a revoked key fails this first
|
|
1002
|
+
# on every recommend for a user with a local history.
|
|
1003
|
+
log_list(e, "recapi: could not restore the user's stored history: #{e.code}: #{e.message}")
|
|
960
1004
|
return state
|
|
961
1005
|
end
|
|
962
1006
|
with_locks(key) do
|