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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -6
  3. data/lib/recapi.rb +63 -19
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8edb463693be71a5cac3a101441d5fe6c045e5e58f4003025291995ec5c9e44
4
- data.tar.gz: 818ea20b257819a76f15c832af44b83694681fc36995f08b91987ac2b9e8afdf
3
+ metadata.gz: 45a27aa9c5eae2fa2088d58247ebd703b60df834bc22e508737d89f17e90a8a2
4
+ data.tar.gz: 7443f75901c5e2600ea181f3a61277bc9b2e5b2820aad2c999dba4b8353521b6
5
5
  SHA512:
6
- metadata.gz: cb54199fc5b2ff480e94be20d594598a2cbda5fb2b1b8ffb854b3dd4ac0dd3d9d2858b05e67cc79b264d93f41f4e5132b57c50812aa5c626582583d369763df7
7
- data.tar.gz: 33fbcbfc1cca21caf75849d106e9500236d6adf2ac897c74b49e9731eb79b6680cb1054e637677babbd0ebc52fc853aa7a999ff10a1e11911da5ff01bb538c41
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.7.rb
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 5xx it returns popular items (`fallback?`, no id), or no item when recapi is down: hide the slot. A 4xx or 429 raises. |
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, so a revoked key on a busy server doesn't
99
- flood the log, and a full queue one line until a send works. A failed write to
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.7"
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. A 429 is
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 5xx it returns popular items (fallback,
555
- # no id) and logs the error. If those fail too (recapi is down), the list
556
- # is empty: hide the slot. A 4xx or 429 raises: a fallback would hide an
557
- # integration bug or a plan limit.
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.outage?
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. Runs under @send_lock.
903
+ # someone looks. A logger given gets them all.
896
904
  def log_lost(kind, message)
897
- if @limit_lost_lines
898
- now = @clock.call
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
- return if last && (now - last).abs < LOST_LINE_MS
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 altogether: an empty list the page can hide beats an
938
- # exception that fails the whole page.
939
- @logger.warn("recapi: recommendations failed (#{error.message}), popular too, served no items: #{e.message}")
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
- @logger.warn("recapi: recommendations failed, served popular items: #{error.message}")
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
- @logger.warn("recapi: could not restore the user's stored history: #{e.message}")
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - recapi