@internetarchive/ia-book-actions 1.4.6-alpha2 → 1.4.6-alpha4
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 +1 -1
- package/src/core/services/actions-handler/actions-handler-service.js +13 -13
- package/src/core/services/loan-token-poller.js +21 -9
- package/src/ia-book-actions.js +17 -1
- package/test/core/services/actions-handler-service.test.js +11 -0
- package/test/core/services/loan-token-poller.test.js +44 -1
- package/test/ia-book-actions.test.js +61 -1
package/package.json
CHANGED
|
@@ -131,11 +131,12 @@ export default async function ActionsHandlerService(options) {
|
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
* Report it, don't just swallow it.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
175
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
// open at all" from "a mid-read refresh
|
|
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({
|
|
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,
|
package/src/ia-book-actions.js
CHANGED
|
@@ -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
|
+
});
|