Update captcha response error handling
This commit is contained in:
parent
1ce3988579
commit
9c918e4d62
5 changed files with 84 additions and 57 deletions
|
@ -14,15 +14,15 @@
|
||||||
|
|
||||||
import { assertDev } from './util/assert';
|
import { assertDev } from './util/assert';
|
||||||
import { isOlderThan } from './util/timestamp';
|
import { isOlderThan } from './util/timestamp';
|
||||||
import { parseRetryAfterWithDefault } from './util/parseRetryAfter';
|
|
||||||
import { clearTimeoutIfNecessary } from './util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from './util/clearTimeoutIfNecessary';
|
||||||
import { missingCaseError } from './util/missingCaseError';
|
import { missingCaseError } from './util/missingCaseError';
|
||||||
import type { StorageInterface } from './types/Storage.d';
|
import type { StorageInterface } from './types/Storage.d';
|
||||||
import * as Errors from './types/errors';
|
import * as Errors from './types/errors';
|
||||||
import { HTTPError } from './textsecure/Errors';
|
import { HTTPError, type SendMessageChallengeData } from './textsecure/Errors';
|
||||||
import type { SendMessageChallengeData } from './textsecure/Errors';
|
|
||||||
import * as log from './logging/log';
|
import * as log from './logging/log';
|
||||||
import { drop } from './util/drop';
|
import { drop } from './util/drop';
|
||||||
|
import { findRetryAfterTimeFromError } from './jobs/helpers/findRetryAfterTimeFromError';
|
||||||
|
import { MINUTE } from './util/durations';
|
||||||
|
|
||||||
export type ChallengeResponse = Readonly<{
|
export type ChallengeResponse = Readonly<{
|
||||||
captcha: string;
|
captcha: string;
|
||||||
|
@ -425,59 +425,53 @@ export class ChallengeHandler {
|
||||||
log.info(`challenge(${reason}): sending challenge to server`);
|
log.info(`challenge(${reason}): sending challenge to server`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendChallengeResponse({
|
await this.options.sendChallengeResponse({
|
||||||
type: 'captcha',
|
type: 'captcha',
|
||||||
token: lastToken,
|
token: lastToken,
|
||||||
captcha,
|
captcha,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If we get an error back from server after solving a captcha, it could be that we
|
||||||
|
// are rate-limited (413, 429), that we need to solve another captcha (428), or any
|
||||||
|
// other possible 4xx, 5xx error.
|
||||||
|
|
||||||
|
// In general, unless we're being rate-limited, we don't want to wait to show
|
||||||
|
// another captcha: this may be a time-critical situation (e.g. user is in a call),
|
||||||
|
// and if the server 500s, for instance, we want to allow the user to immediately
|
||||||
|
// try again.
|
||||||
|
let defaultRetryAfter = 0;
|
||||||
|
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
if ([413, 429].includes(error.code)) {
|
||||||
|
// These rate-limit codes should have a retry-after in the response, but just in
|
||||||
|
// case, let's wait a minute
|
||||||
|
defaultRetryAfter = MINUTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = findRetryAfterTimeFromError(error, defaultRetryAfter);
|
||||||
|
|
||||||
log.error(
|
log.error(
|
||||||
`challenge(${reason}): challenge failure, error:`,
|
`challenge(${reason}): challenge solve failure; will retry after ${retryAfter}ms; error:`,
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
if (error.code === 413 || error.code === 429) {
|
|
||||||
this.options.setChallengeStatus('idle');
|
const retryAt = retryAfter + Date.now();
|
||||||
} else {
|
|
||||||
this.options.setChallengeStatus('required');
|
// Remove the challenge dialog, and trigger the conversationJobQueue to retry the
|
||||||
}
|
// sends, which will likely trigger another captcha
|
||||||
this.solving -= 1;
|
this.options.setChallengeStatus('idle');
|
||||||
|
this.options.onChallengeFailed(retryAfter);
|
||||||
|
this.forceWaitOnAll(retryAt);
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
this.solving -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`challenge(${reason}): challenge success. force sending`);
|
log.info(`challenge(${reason}): challenge success. force sending`);
|
||||||
|
|
||||||
this.options.setChallengeStatus('idle');
|
this.options.setChallengeStatus('idle');
|
||||||
|
|
||||||
this.startAllQueues({ force: true });
|
|
||||||
this.solving -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendChallengeResponse(data: ChallengeData): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.options.sendChallengeResponse(data);
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
!(error instanceof HTTPError) ||
|
|
||||||
!(error.code === 413 || error.code === 429) ||
|
|
||||||
!error.responseHeaders
|
|
||||||
) {
|
|
||||||
this.options.onChallengeFailed();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryAfter = parseRetryAfterWithDefault(
|
|
||||||
error.responseHeaders['retry-after']
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info(`challenge: retry after ${retryAfter}ms`);
|
|
||||||
const retryAt = retryAfter + Date.now();
|
|
||||||
this.forceWaitOnAll(retryAt);
|
|
||||||
|
|
||||||
this.options.onChallengeFailed(retryAfter);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options.onChallengeSolved();
|
this.options.onChallengeSolved();
|
||||||
|
this.startAllQueues({ force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,10 @@ import { isRecord } from '../../util/isRecord';
|
||||||
import { HTTPError } from '../../textsecure/Errors';
|
import { HTTPError } from '../../textsecure/Errors';
|
||||||
import { parseRetryAfterWithDefault } from '../../util/parseRetryAfter';
|
import { parseRetryAfterWithDefault } from '../../util/parseRetryAfter';
|
||||||
|
|
||||||
export function findRetryAfterTimeFromError(err: unknown): number {
|
export function findRetryAfterTimeFromError(
|
||||||
|
err: unknown,
|
||||||
|
defaultValue?: number
|
||||||
|
): number {
|
||||||
let rawValue: unknown;
|
let rawValue: unknown;
|
||||||
|
|
||||||
if (isRecord(err)) {
|
if (isRecord(err)) {
|
||||||
|
@ -17,8 +20,8 @@ export function findRetryAfterTimeFromError(err: unknown): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(rawValue)) {
|
if (Array.isArray(rawValue)) {
|
||||||
return parseRetryAfterWithDefault(rawValue[0]);
|
return parseRetryAfterWithDefault(rawValue[0], defaultValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseRetryAfterWithDefault(rawValue);
|
return parseRetryAfterWithDefault(rawValue, defaultValue);
|
||||||
}
|
}
|
||||||
|
|
|
@ -259,7 +259,7 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a toast and not another challenge if completion results in 413', async () => {
|
it('if server rejects our captcha, should show a toast and defer challenge based on error code', async () => {
|
||||||
const { server, desktop } = bootstrap;
|
const { server, desktop } = bootstrap;
|
||||||
|
|
||||||
debug(
|
debug(
|
||||||
|
@ -296,21 +296,47 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First, challenge returns 428 (try again) */
|
||||||
debug('Waiting for challenge');
|
debug('Waiting for challenge');
|
||||||
const request = await app.waitForChallenge();
|
const firstChallengeRequest = await app.waitForChallenge();
|
||||||
|
const challengeDialog = await window
|
||||||
|
.getByTestId('CaptchaDialog.pending')
|
||||||
|
.elementHandle();
|
||||||
|
|
||||||
|
assert.exists(challengeDialog);
|
||||||
|
server.respondToChallengesWith(428);
|
||||||
|
|
||||||
|
debug('Solving challenge');
|
||||||
|
await app.solveChallenge({
|
||||||
|
seq: firstChallengeRequest.seq,
|
||||||
|
data: { captcha: 'anything' },
|
||||||
|
});
|
||||||
|
|
||||||
|
debug('Waiting for verification failure toast');
|
||||||
|
const failedChallengeToastLocator = window.locator(
|
||||||
|
'.Toast__content >> "Verification failed. Please retry later."'
|
||||||
|
);
|
||||||
|
await failedChallengeToastLocator.isVisible();
|
||||||
|
// The existing dialog is removed, but then the conversations will retry their sends,
|
||||||
|
// which will result in another one
|
||||||
|
await challengeDialog.isHidden();
|
||||||
|
|
||||||
|
/** Second, challenge returns 413 (rate limit) */
|
||||||
|
debug(
|
||||||
|
'Waiting for second challenge, should be triggered quickly with the sends being retried'
|
||||||
|
);
|
||||||
|
const secondChallengeRequest = await app.waitForChallenge();
|
||||||
|
|
||||||
server.respondToChallengesWith(413);
|
server.respondToChallengesWith(413);
|
||||||
|
|
||||||
debug('Solving challenge');
|
debug('Solving challenge');
|
||||||
await app.solveChallenge({
|
await app.solveChallenge({
|
||||||
seq: request.seq,
|
seq: secondChallengeRequest.seq,
|
||||||
data: { captcha: 'anything' },
|
data: { captcha: 'anything' },
|
||||||
});
|
});
|
||||||
|
|
||||||
debug('Waiting for verification failure toast');
|
debug('Waiting for verification failure toast');
|
||||||
await window
|
await failedChallengeToastLocator.isVisible();
|
||||||
.locator('.Toast__content >> "Verification failed. Please retry later."')
|
|
||||||
.isVisible();
|
|
||||||
|
|
||||||
debug('Sending another message - this time it should not trigger captcha!');
|
debug('Sending another message - this time it should not trigger captcha!');
|
||||||
{
|
{
|
||||||
|
@ -346,8 +372,8 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
|
||||||
debug('Checking for no other captcha dialogs');
|
debug('Checking for no other captcha dialogs');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await app.getPendingEventCount('captchaDialog'),
|
await app.getPendingEventCount('captchaDialog'),
|
||||||
1,
|
2,
|
||||||
'Just one captcha dialog, the first one'
|
'Just two captcha dialogs, the first one, and the one after the 428'
|
||||||
);
|
);
|
||||||
|
|
||||||
const requests = server.stopRateLimiting({
|
const requests = server.stopRateLimiting({
|
||||||
|
@ -356,7 +382,7 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
|
||||||
});
|
});
|
||||||
|
|
||||||
debug(`Rate-limited requests: ${requests}`);
|
debug(`Rate-limited requests: ${requests}`);
|
||||||
assert.strictEqual(requests, 1, 'rate limit requests');
|
assert.strictEqual(requests, 2, 'rate limit requests');
|
||||||
|
|
||||||
const requestsContactB = server.stopRateLimiting({
|
const requestsContactB = server.stopRateLimiting({
|
||||||
source: desktop.aci,
|
source: desktop.aci,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { HTTPError } from '../../../textsecure/Errors';
|
||||||
import { MINUTE } from '../../../util/durations';
|
import { MINUTE } from '../../../util/durations';
|
||||||
|
|
||||||
describe('findRetryAfterTimeFromError', () => {
|
describe('findRetryAfterTimeFromError', () => {
|
||||||
it('returns 1 minute if no Retry-After time is found', () => {
|
it('returns 1 minute or provided default if no Retry-After time is found', () => {
|
||||||
[
|
[
|
||||||
undefined,
|
undefined,
|
||||||
null,
|
null,
|
||||||
|
@ -47,6 +47,7 @@ describe('findRetryAfterTimeFromError', () => {
|
||||||
},
|
},
|
||||||
].forEach(input => {
|
].forEach(input => {
|
||||||
assert.strictEqual(findRetryAfterTimeFromError(input), MINUTE);
|
assert.strictEqual(findRetryAfterTimeFromError(input), MINUTE);
|
||||||
|
assert.strictEqual(findRetryAfterTimeFromError(input, 42), 42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -7,10 +7,13 @@ import { isNormalNumber } from './isNormalNumber';
|
||||||
const DEFAULT_RETRY_AFTER = MINUTE;
|
const DEFAULT_RETRY_AFTER = MINUTE;
|
||||||
const MINIMAL_RETRY_AFTER = SECOND;
|
const MINIMAL_RETRY_AFTER = SECOND;
|
||||||
|
|
||||||
export function parseRetryAfterWithDefault(value: unknown): number {
|
export function parseRetryAfterWithDefault(
|
||||||
|
value: unknown,
|
||||||
|
defaultValue: number = DEFAULT_RETRY_AFTER
|
||||||
|
): number {
|
||||||
const retryAfter = parseRetryAfter(value);
|
const retryAfter = parseRetryAfter(value);
|
||||||
if (retryAfter === undefined) {
|
if (retryAfter === undefined) {
|
||||||
return DEFAULT_RETRY_AFTER;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(retryAfter, MINIMAL_RETRY_AFTER);
|
return Math.max(retryAfter, MINIMAL_RETRY_AFTER);
|
||||||
|
|
Loading…
Add table
Reference in a new issue