@internetarchive/ia-book-actions 1.4.6-alpha1 → 1.4.6-alpha3

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.
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.6-alpha1",
10
+ "version": "1.4.6-alpha3",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -131,11 +131,12 @@ export default async function ActionsHandlerService(options) {
131
131
  });
132
132
 
133
133
  /**
134
- * Report it, don't just swallow it. A rejected fetch (offline, dropped
135
- * connection) lands here — a non-JSON body no longer does, see
136
- * parseServiceResponse() — as does anything thrown after the response
137
- * arrived; calling neither `success` nor `error` leaves every caller
138
- * waiting on a callback that never comes. Only the former is a network
134
+ * Report it, don't just swallow it. This catches a rejected fetch
135
+ * (offline, dropped or blocked connection) and anything thrown after the
136
+ * response arrived, including by the caller's own callbacks. Non-JSON
137
+ * bodies are handled in parseServiceResponse() and don't reach here.
138
+ * Calling neither `success` nor `error` would leave every caller waiting
139
+ * on a callback that never comes. Only a rejected fetch is a network
139
140
  * problem, so only that one says so.
140
141
  *
141
142
  * For renew_loan that's not merely a missing modal: IABookActions
@@ -145,9 +146,13 @@ export default async function ActionsHandlerService(options) {
145
146
  * connection is exactly when this fires.
146
147
  */
147
148
  log(`[IABookActions] ✗ ${option.action} threw`, error);
149
+ // networkFailure marks "the service never answered" (like
150
+ // nonJsonResponse in parseServiceResponse) — see
151
+ // IABookActions.handleLendingActionError.
148
152
  option?.error?.({
149
153
  error: reachedResponse ? unexpectedError : networkError,
150
154
  detail: String(error),
155
+ ...(reachedResponse ? {} : { networkFailure: true }),
151
156
  });
152
157
  }
153
158
  }
@@ -167,16 +172,15 @@ export async function parseServiceResponse(response, option) {
167
172
  const { status } = response;
168
173
  const contentType = response.headers?.get('content-type') || '';
169
174
 
170
- // The service always answers with JSON (application/json, or
171
- // application/problem+json for OPDS) — anything else never came from it.
175
+ // The service always answers with JSON; a body that doesn't parse never
176
+ // came from it. Parsed regardless of Content-Type, so a valid answer is
177
+ // never mistaken for an error page if a header is ever missing.
172
178
  const body = await response.text();
173
179
  let data;
174
- if (contentType.includes('json')) {
175
- try {
176
- data = JSON.parse(body);
177
- } catch (parseError) {
178
- data = undefined;
179
- }
180
+ try {
181
+ data = JSON.parse(body);
182
+ } catch (parseError) {
183
+ data = undefined;
180
184
  }
181
185
 
182
186
  if (data === undefined) {
@@ -47,6 +47,13 @@ export class LoanTokenPoller {
47
47
 
48
48
  this.loanTokenInterval = undefined;
49
49
 
50
+ /**
51
+ * create_token failures in a row since the last success, passed to
52
+ * errorCallback so the consumer can tell a one-off blip from a run of
53
+ * them — see IABookActions.handleLendingActionError.
54
+ */
55
+ this.consecutiveFailures = 0;
56
+
50
57
  /**
51
58
  * loan analytics instance
52
59
  * @see loan-analytics.js
@@ -63,12 +70,9 @@ export class LoanTokenPoller {
63
70
  async bookAccessed() {
64
71
  if (this.borrowType) {
65
72
  if (this.skipInitialCall) {
66
- log(
67
- '[LoanTokenPoller] skipping initial create_token — already minted by the renewal response',
68
- {
69
- identifier: this.identifier,
70
- }
71
- );
73
+ log('[LoanTokenPoller] skipping initial create_token — already minted by the renewal response', {
74
+ identifier: this.identifier,
75
+ });
72
76
  } else {
73
77
  // Do an initial token, then set an interval
74
78
  this.handleLoanTokenPoller(true);
@@ -116,6 +120,7 @@ export class LoanTokenPoller {
116
120
  identifier: this.identifier,
117
121
  isInitial,
118
122
  });
123
+ this.consecutiveFailures = 0;
119
124
  if (isInitial) this.successCallback();
120
125
  },
121
126
  });
@@ -133,17 +138,23 @@ export class LoanTokenPoller {
133
138
  */
134
139
  handleTokenError(data, isInitial) {
135
140
  const action = 'create_token';
141
+ this.consecutiveFailures += 1;
142
+ const { consecutiveFailures } = this;
136
143
 
137
144
  log('[LoanTokenPoller] create_token failed', {
138
145
  identifier: this.identifier,
139
146
  isInitial,
147
+ consecutiveFailures,
140
148
  error: data?.error,
141
149
  });
142
150
 
143
- // isInitial rides along so the consumer can distinguish "the book won't
144
- // open at all" from "a mid-read refresh blipped" — see
151
+ // isInitial and consecutiveFailures ride along so the consumer can
152
+ // distinguish "the book won't open at all" from "a mid-read refresh
153
+ // blipped", and a one-off blip from a run of them — see
145
154
  // IABookActions.handleLendingActionError.
146
- this.errorCallback({ detail: { action, data, isInitial } });
155
+ this.errorCallback({
156
+ detail: { action, data, isInitial, consecutiveFailures },
157
+ });
147
158
 
148
159
  // send error to Sentry
149
160
  sentryCaptureMessage(
@@ -154,6 +165,7 @@ export class LoanTokenPoller {
154
165
  extra: {
155
166
  borrowType: this.borrowType,
156
167
  isInitial,
168
+ consecutiveFailures,
157
169
  error: data?.error,
158
170
  status: data?.status,
159
171
  nonJsonResponse: data?.nonJsonResponse === true,
@@ -42,6 +42,16 @@ export const modalButtonStyle = {
42
42
  'background:none;font-size:inherit;border:0;padding:0;color:#0000ee;cursor:pointer;text-decoration:underline',
43
43
  };
44
44
 
45
+ /**
46
+ * How many create_token refreshes in a row may fail without an answer from
47
+ * the service before the patron is shown the error modal. Each successful
48
+ * refresh mints an access token good for 10 minutes (petabox
49
+ * LendingConfig::BOOKREADER_AUTH_SECONDS) and refreshes run every 2 minutes
50
+ * (tokenDelay), so the 3rd failure in a row (~6 minutes on) still shows the
51
+ * modal before that token runs out and pages stop loading.
52
+ */
53
+ export const MAX_SILENT_TOKEN_REFRESH_FAILURES = 2;
54
+
45
55
  export default class IABookActions extends LitElement {
46
56
  static get properties() {
47
57
  return {
@@ -1014,36 +1024,44 @@ export default class IABookActions extends LitElement {
1014
1024
  recoveringFromLoanExpiry: this.recoveringFromLoanExpiry,
1015
1025
  });
1016
1026
 
1027
+ const consecutiveFailures = event?.detail?.consecutiveFailures ?? 1;
1017
1028
  if (
1018
1029
  action === 'create_token' &&
1019
1030
  !isInitial &&
1020
- eventData?.nonJsonResponse === true &&
1021
- eventData?.status === 400
1031
+ (eventData?.nonJsonResponse === true ||
1032
+ eventData?.networkFailure === true) &&
1033
+ consecutiveFailures <= MAX_SILENT_TOKEN_REFRESH_FAILURES
1022
1034
  ) {
1023
- // Something in front of the service (the WAF's /about/400.html)
1024
- // answered a routine refresh instead of the service itself. It's
1025
- // already reported to Sentry (ActionsHandlerService/LoanTokenPoller);
1026
- // the current access token keeps the book readable, and the poll
1027
- // interval is left running, so the next tick tries again. No modal,
1028
- // and no session teardown.
1035
+ // The service never answered this routine refresh: something in
1036
+ // front of it did (the WAF's /about/400.html, an outage page), or the
1037
+ // request never got through. That says nothing about the loan, so
1038
+ // no modal and no session teardown — it's already reported to Sentry
1039
+ // (ActionsHandlerService/LoanTokenPoller), the current access token
1040
+ // keeps the book readable, and the poll interval is left running, so
1041
+ // the next tick tries again. The service's own answer (e.g. "You do
1042
+ // not currently have this book borrowed.") still falls through below.
1029
1043
  //
1030
1044
  // Not for the initial call: its success is what runs
1031
1045
  // lendingBarPostInit() and opens the book (see startLoanTokenPoller),
1032
1046
  // so suppressing it would leave a reader that never loads, silently.
1047
+ //
1048
+ // And not forever: past MAX_SILENT_TOKEN_REFRESH_FAILURES in a row,
1049
+ // the last good access token is about to expire, so fall through to
1050
+ // the modal rather than let the reader break with nothing shown.
1033
1051
  log(
1034
- '[IABookActions] create_token HTML 400 on a routine refresh — no modal',
1052
+ '[IABookActions] create_token routine refresh got no answer from the service — no modal',
1035
1053
  {
1036
1054
  identifier: this.identifier,
1037
1055
  status: eventData?.status,
1056
+ errorMsg,
1038
1057
  }
1039
1058
  );
1040
1059
  return;
1041
1060
  }
1042
1061
 
1043
1062
  if (action === 'create_token') {
1044
- // Any other create_token failure — initial or a routine interval
1045
- // refresh, except the routine HTML 400 above — means access can no
1046
- // longer be confirmed (e.g. a stale loan-record
1063
+ // Any other create_token failure — initial, or a routine refresh the
1064
+ // service itself answered — means access can no longer be confirmed (e.g. a stale loan-record
1047
1065
  // cache on some other node can make this request fail even though
1048
1066
  // the loan is fine, or the loan may genuinely have just been
1049
1067
  // returned via BookReaderImages.php's revokeAccess()). Don't retry
@@ -59,6 +59,17 @@ describe('ActionsHandlerService#parseServiceResponse', () => {
59
59
  expect(context.extra.bodySnippet).to.contain('<!DOCTYPE');
60
60
  });
61
61
 
62
+ it("treats a JSON body as the service's answer even without a JSON content type", async () => {
63
+ const body = { success: true, token: 'abc' };
64
+ const response = new Response(JSON.stringify(body), {
65
+ status: 200,
66
+ headers: { 'Content-Type': 'text/html' },
67
+ });
68
+
69
+ expect(await parseServiceResponse(response, option)).to.deep.equal(body);
70
+ expect(window.Sentry.captureMessage.called).to.be.false;
71
+ });
72
+
62
73
  it('treats a JSON content type with an unparseable body as non-JSON', async () => {
63
74
  const response = new Response('<!DOCTYPE html>', {
64
75
  status: 502,
@@ -135,6 +146,7 @@ describe('ActionsHandlerService - thrown errors', () => {
135
146
 
136
147
  expect(error.calledOnce).to.be.true;
137
148
  expect(error.firstCall.args[0].error).to.equal(networkError);
149
+ expect(error.firstCall.args[0].networkFailure).to.be.true;
138
150
  const [, context] = window.Sentry.captureException.firstCall.args;
139
151
  expect(context.tags).to.deep.equal({
140
152
  identifier: 'foo',
@@ -158,5 +170,7 @@ describe('ActionsHandlerService - thrown errors', () => {
158
170
  expect(error.calledOnce).to.be.true;
159
171
  expect(error.firstCall.args[0].error).to.equal(unexpectedError);
160
172
  expect(error.firstCall.args[0].detail).to.contain('boom');
173
+ // the service did answer, so this isn't treated as "no answer"
174
+ expect(error.firstCall.args[0].networkFailure).to.be.undefined;
161
175
  });
162
176
  });
@@ -1,4 +1,4 @@
1
- import { expect } from '@open-wc/testing';
1
+ import { expect, aTimeout } from '@open-wc/testing';
2
2
  import Sinon from 'sinon';
3
3
 
4
4
  import { LoanTokenPoller } from '../../../src/core/services/loan-token-poller.js';
@@ -223,3 +223,46 @@ describe('handleTokenError - HTML 400 from the WAF (WEBDEV-9595)', () => {
223
223
  poller.disconnectedCallback();
224
224
  });
225
225
  });
226
+
227
+ describe('consecutive create_token failures (WEBDEV-9595)', () => {
228
+ const makePoller = errorCallback =>
229
+ new LoanTokenPoller({
230
+ identifier: 'identifier1',
231
+ borrowType: 'borrowed',
232
+ successCallback: () => {},
233
+ errorCallback,
234
+ pollerDelay: 120,
235
+ skipInitialCall: true,
236
+ });
237
+
238
+ it('passes the count of failures in a row to errorCallback', () => {
239
+ const errorCallback = Sinon.spy();
240
+ const poller = makePoller(errorCallback);
241
+
242
+ poller.handleTokenError({ error: 'x' }, false);
243
+ poller.handleTokenError({ error: 'x' }, false);
244
+ poller.handleTokenError({ error: 'x' }, false);
245
+
246
+ expect(
247
+ errorCallback.getCalls().map(c => c.args[0].detail.consecutiveFailures)
248
+ ).to.deep.equal([1, 2, 3]);
249
+ poller.disconnectedCallback();
250
+ });
251
+
252
+ it('resets the count after a successful refresh', async () => {
253
+ const errorCallback = Sinon.spy();
254
+ const poller = makePoller(errorCallback);
255
+
256
+ poller.handleTokenError({ error: 'x' }, false);
257
+ poller.handleTokenError({ error: 'x' }, false);
258
+ // on localhost the service fakes a create_token success
259
+ poller.handleLoanTokenPoller(false);
260
+ await aTimeout(300);
261
+ poller.handleTokenError({ error: 'x' }, false);
262
+
263
+ expect(errorCallback.lastCall.args[0].detail.consecutiveFailures).to.equal(
264
+ 1
265
+ );
266
+ poller.disconnectedCallback();
267
+ });
268
+ });
@@ -1,11 +1,14 @@
1
1
  import { html, fixture, expect, aTimeout } from '@open-wc/testing';
2
2
  import '@internetarchive/modal-manager';
3
- import '../src/ia-book-actions.js';
4
3
  import Sinon from 'sinon';
5
4
 
6
5
  import { SharedResizeObserver } from '@internetarchive/shared-resize-observer';
7
6
  import { LocalCache } from '@internetarchive/local-cache';
8
- import { unexpectedResponseError } from '../src/core/services/actions-handler/actions-handler-service.js';
7
+ import { MAX_SILENT_TOKEN_REFRESH_FAILURES } from '../src/ia-book-actions.js';
8
+ import {
9
+ unexpectedResponseError,
10
+ networkError,
11
+ } from '../src/core/services/actions-handler/actions-handler-service.js';
9
12
 
10
13
  beforeEach(() => {
11
14
  const modalManager = document.createElement('modal-manager');
@@ -1734,93 +1737,157 @@ describe('Sentry reports (WEBDEV-9595)', () => {
1734
1737
  });
1735
1738
  });
1736
1739
 
1737
- describe('handleLendingActionError - HTML 400 on create_token (WEBDEV-9595)', () => {
1740
+ describe('handleLendingActionError - routine create_token refresh failures (WEBDEV-9595)', () => {
1738
1741
  const htmlBadRequest = {
1739
1742
  success: false,
1740
1743
  error: unexpectedResponseError(400),
1741
1744
  status: 400,
1742
1745
  nonJsonResponse: true,
1743
1746
  };
1747
+ // the service never answered: no modal on a routine refresh
1748
+ const noAnswer = {
1749
+ 'an HTML 400 (WAF block)': htmlBadRequest,
1750
+ 'an HTML 502': {
1751
+ ...htmlBadRequest,
1752
+ error: unexpectedResponseError(502),
1753
+ status: 502,
1754
+ },
1755
+ 'a blocked or failed request': {
1756
+ error: networkError,
1757
+ detail: 'TypeError: Failed to fetch',
1758
+ networkFailure: true,
1759
+ },
1760
+ };
1761
+ // the service answered "no": the loan may really be gone
1762
+ const serviceAnswer = {
1763
+ success: false,
1764
+ error: 'You do not currently have this book borrowed.',
1765
+ status: 400,
1766
+ };
1744
1767
  const liveLoan = {
1745
1768
  is_lendable: true,
1746
1769
  user_has_borrowed: true,
1747
1770
  browsingExpired: false,
1748
1771
  secondsLeftOnLoan: 300,
1749
1772
  };
1750
-
1751
- it('shows no modal and keeps the session going on a routine refresh', async () => {
1773
+ const setup = async () => {
1752
1774
  const el = await fixture(
1753
1775
  container({
1754
1776
  userid: '@user1',
1755
- identifier: 'html-400-routine',
1777
+ identifier: 'token-refresh-failure',
1756
1778
  lendingStatus: liveLoan,
1757
1779
  })
1758
1780
  );
1759
1781
  await el.updateComplete;
1760
- const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1761
- const clearAllSpy = Sinon.spy(window.IALendingIntervals, 'clearAll');
1762
- el.tokenPoller = { disconnectedCallback: Sinon.spy() };
1782
+ return el;
1783
+ };
1784
+
1785
+ for (const [name, data] of Object.entries(noAnswer)) {
1786
+ it(`shows no modal and keeps the session going for ${name}`, async () => {
1787
+ const el = await setup();
1788
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1789
+ const clearAllSpy = Sinon.spy(window.IALendingIntervals, 'clearAll');
1790
+ el.tokenPoller = { disconnectedCallback: Sinon.spy() };
1791
+
1792
+ try {
1793
+ el.handleLendingActionError({
1794
+ detail: { action: 'create_token', isInitial: false, data },
1795
+ });
1796
+
1797
+ expect(showErrorModalSpy.called).to.be.false;
1798
+ // none of the create_token teardown ran
1799
+ expect(clearAllSpy.called).to.be.false;
1800
+ expect(el.tokenPoller.disconnectedCallback.called).to.be.false;
1801
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(300);
1802
+ expect(el.lendingStatus.available_to_browse).to.be.undefined;
1803
+ } finally {
1804
+ clearAllSpy.restore();
1805
+ }
1806
+ });
1807
+
1808
+ it(`still shows the modal for ${name} when the book is first opening`, async () => {
1809
+ const el = await setup();
1810
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1763
1811
 
1764
- try {
1765
1812
  el.handleLendingActionError({
1766
- detail: { action: 'create_token', isInitial: false, data: htmlBadRequest },
1813
+ detail: { action: 'create_token', isInitial: true, data },
1767
1814
  });
1768
1815
 
1769
- expect(showErrorModalSpy.called).to.be.false;
1770
- // none of the create_token teardown ran
1771
- expect(clearAllSpy.called).to.be.false;
1772
- expect(el.tokenPoller.disconnectedCallback.called).to.be.false;
1773
- expect(el.lendingStatus.secondsLeftOnLoan).to.equal(300);
1774
- expect(el.lendingStatus.available_to_browse).to.be.undefined;
1775
- } finally {
1776
- clearAllSpy.restore();
1777
- }
1778
- });
1816
+ expect(showErrorModalSpy.calledOnce).to.be.true;
1817
+ });
1818
+ }
1779
1819
 
1780
- it('still shows the modal when the book is first opening', async () => {
1781
- const el = await fixture(
1782
- container({
1783
- userid: '@user1',
1784
- identifier: 'html-400-initial',
1785
- lendingStatus: liveLoan,
1786
- })
1787
- );
1788
- await el.updateComplete;
1820
+ it("still shows the modal and resets the bar when the service itself says no", async () => {
1821
+ const el = await setup();
1789
1822
  const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1790
1823
 
1791
1824
  el.handleLendingActionError({
1792
- detail: { action: 'create_token', isInitial: true, data: htmlBadRequest },
1825
+ detail: { action: 'create_token', isInitial: false, data: serviceAnswer },
1793
1826
  });
1794
1827
 
1795
- expect(showErrorModalSpy.calledOnce).to.be.true;
1828
+ expect(
1829
+ showErrorModalSpy.calledOnceWith(serviceAnswer.error, 'create_token')
1830
+ ).to.be.true;
1831
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1796
1832
  });
1833
+ });
1797
1834
 
1798
- it("still shows the modal for the service's own JSON 400 and for an HTML 5xx", async () => {
1835
+ describe('handleLendingActionError - a run of unanswered token refreshes (WEBDEV-9595)', () => {
1836
+ const blocked = {
1837
+ error: networkError,
1838
+ detail: 'TypeError: Failed to fetch',
1839
+ networkFailure: true,
1840
+ };
1841
+ const setup = async () => {
1799
1842
  const el = await fixture(
1800
1843
  container({
1801
1844
  userid: '@user1',
1802
- identifier: 'html-400-other',
1803
- lendingStatus: liveLoan,
1845
+ identifier: 'token-refresh-run',
1846
+ lendingStatus: {
1847
+ is_lendable: true,
1848
+ user_has_borrowed: true,
1849
+ browsingExpired: false,
1850
+ secondsLeftOnLoan: 300,
1851
+ },
1804
1852
  })
1805
1853
  );
1806
1854
  await el.updateComplete;
1855
+ return el;
1856
+ };
1857
+
1858
+ it(`stays silent for up to ${MAX_SILENT_TOKEN_REFRESH_FAILURES} failures in a row`, async () => {
1859
+ const el = await setup();
1860
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1861
+
1862
+ for (let n = 1; n <= MAX_SILENT_TOKEN_REFRESH_FAILURES; n += 1) {
1863
+ el.handleLendingActionError({
1864
+ detail: {
1865
+ action: 'create_token',
1866
+ isInitial: false,
1867
+ consecutiveFailures: n,
1868
+ data: blocked,
1869
+ },
1870
+ });
1871
+ }
1872
+
1873
+ expect(showErrorModalSpy.called).to.be.false;
1874
+ });
1875
+
1876
+ it('shows the modal and resets the bar on the next one, before the access token runs out', async () => {
1877
+ const el = await setup();
1807
1878
  const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1808
1879
 
1809
1880
  el.handleLendingActionError({
1810
1881
  detail: {
1811
1882
  action: 'create_token',
1812
1883
  isInitial: false,
1813
- data: { error: 'You do not currently have this book borrowed.' },
1814
- },
1815
- });
1816
- el.handleLendingActionError({
1817
- detail: {
1818
- action: 'create_token',
1819
- isInitial: false,
1820
- data: { ...htmlBadRequest, status: 502 },
1884
+ consecutiveFailures: MAX_SILENT_TOKEN_REFRESH_FAILURES + 1,
1885
+ data: blocked,
1821
1886
  },
1822
1887
  });
1823
1888
 
1824
- expect(showErrorModalSpy.calledTwice).to.be.true;
1889
+ expect(showErrorModalSpy.calledOnceWith(networkError, 'create_token')).to
1890
+ .be.true;
1891
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1825
1892
  });
1826
1893
  });