@internetarchive/ia-book-actions 1.4.6-alpha2 → 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-alpha2",
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
@@ -171,16 +172,15 @@ export async function parseServiceResponse(response, option) {
171
172
  const { status } = response;
172
173
  const contentType = response.headers?.get('content-type') || '';
173
174
 
174
- // The service always answers with JSON (application/json, or
175
- // 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.
176
178
  const body = await response.text();
177
179
  let data;
178
- if (contentType.includes('json')) {
179
- try {
180
- data = JSON.parse(body);
181
- } catch (parseError) {
182
- data = undefined;
183
- }
180
+ try {
181
+ data = JSON.parse(body);
182
+ } catch (parseError) {
183
+ data = undefined;
184
184
  }
185
185
 
186
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,11 +1024,13 @@ 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
1031
  (eventData?.nonJsonResponse === true ||
1021
- eventData?.networkFailure === true)
1032
+ eventData?.networkFailure === true) &&
1033
+ consecutiveFailures <= MAX_SILENT_TOKEN_REFRESH_FAILURES
1022
1034
  ) {
1023
1035
  // The service never answered this routine refresh: something in
1024
1036
  // front of it did (the WAF's /about/400.html, an outage page), or the
@@ -1032,6 +1044,10 @@ export default class IABookActions extends LitElement {
1032
1044
  // Not for the initial call: its success is what runs
1033
1045
  // lendingBarPostInit() and opens the book (see startLoanTokenPoller),
1034
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.
1035
1051
  log(
1036
1052
  '[IABookActions] create_token routine refresh got no answer from the service — no modal',
1037
1053
  {
@@ -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,
@@ -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,10 +1,10 @@
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';
7
+ import { MAX_SILENT_TOKEN_REFRESH_FAILURES } from '../src/ia-book-actions.js';
8
8
  import {
9
9
  unexpectedResponseError,
10
10
  networkError,
@@ -1831,3 +1831,63 @@ describe('handleLendingActionError - routine create_token refresh failures (WEBD
1831
1831
  expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1832
1832
  });
1833
1833
  });
1834
+
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 () => {
1842
+ const el = await fixture(
1843
+ container({
1844
+ userid: '@user1',
1845
+ identifier: 'token-refresh-run',
1846
+ lendingStatus: {
1847
+ is_lendable: true,
1848
+ user_has_borrowed: true,
1849
+ browsingExpired: false,
1850
+ secondsLeftOnLoan: 300,
1851
+ },
1852
+ })
1853
+ );
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();
1878
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1879
+
1880
+ el.handleLendingActionError({
1881
+ detail: {
1882
+ action: 'create_token',
1883
+ isInitial: false,
1884
+ consecutiveFailures: MAX_SILENT_TOKEN_REFRESH_FAILURES + 1,
1885
+ data: blocked,
1886
+ },
1887
+ });
1888
+
1889
+ expect(showErrorModalSpy.calledOnceWith(networkError, 'create_token')).to
1890
+ .be.true;
1891
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1892
+ });
1893
+ });