Skip to content

Commit 7e8a95f

Browse files
committed
task(recovery-phone): Support rotating from phone number
1 parent b036be5 commit 7e8a95f

9 files changed

Lines changed: 171 additions & 8 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { BaseError } from '@fxa/shared/error';
77
// See full list of codes here, https://www.twilio.com/docs/api/errors
88
export const TwilioErrorCodes = {
99
['INVALID_TO_PHONE_NUMBER']: 21211,
10+
['SMS_SEND_RATE_LIMIT_EXCEEDED']: 14107,
1011
};
1112

1213
export class RecoveryPhoneError extends BaseError {
@@ -42,3 +43,18 @@ export class RecoveryNumberNotSupportedError extends RecoveryPhoneError {
4243
super('Phone number not supported.', { phoneNumber }, cause);
4344
}
4445
}
46+
47+
export class SmsSendRateLimitExceededError extends RecoveryPhoneError {
48+
constructor(
49+
uid: string,
50+
toPhoneNumber: string,
51+
fromPhoneNumber: string,
52+
cause?: Error
53+
) {
54+
super(
55+
'Too many SMS are currently being sent. Try again later.',
56+
{ uid, toPhoneNumber, fromPhoneNumber },
57+
cause
58+
);
59+
}
60+
}

libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import { Test, TestingModule } from '@nestjs/testing';
88
import { SmsManager } from './sms.manager';
99
import { SmsManagerConfig } from './sms.manger.config';
1010
import { TwilioProvider } from './twilio.provider';
11+
import { TwilioErrorCodes } from './recovery-phone.errors';
1112

1213
describe('SmsManager', () => {
1314
const to = '+15005551111';
14-
const from = '+15005550000';
15+
const from = ['+15005550000', '+15005550001'];
1516
const mockTwilioSmsClient = {
1617
messages: {
1718
create: jest.fn(),
@@ -27,6 +28,7 @@ describe('SmsManager', () => {
2728
from,
2829
validNumberPrefixes: ['+1'],
2930
maxMessageLength: 160,
31+
maxRetries: 2,
3032
};
3133

3234
let manager: SmsManager;
@@ -68,7 +70,7 @@ describe('SmsManager', () => {
6870
expect(msg).toBeDefined();
6971
expect(mockTwilioSmsClient.messages.create).toBeCalledWith({
7072
to,
71-
from,
73+
from: from[0],
7274
body,
7375
});
7476
expect(msg?.status).toEqual('sent');
@@ -105,6 +107,62 @@ describe('SmsManager', () => {
105107
);
106108
});
107109

110+
it('Retries if send rate limit exceeded.', async () => {
111+
const mockError = new Error();
112+
(mockError as any).code = TwilioErrorCodes.SMS_SEND_RATE_LIMIT_EXCEEDED;
113+
mockTwilioSmsClient.messages.create.mockRejectedValueOnce(mockError);
114+
mockTwilioSmsClient.messages.create.mockReturnValueOnce({
115+
sid: 'foo',
116+
status: 'sent',
117+
});
118+
const body = 'test success';
119+
const msg = await manager.sendSMS({
120+
to,
121+
body,
122+
});
123+
124+
expect(msg).toBeDefined();
125+
expect(mockTwilioSmsClient.messages.create).toBeCalledWith({
126+
to,
127+
from: from[0],
128+
body,
129+
});
130+
expect(mockTwilioSmsClient.messages.create).toBeCalledWith({
131+
to,
132+
from: from[0],
133+
body,
134+
});
135+
expect(mockTwilioSmsClient.messages.create).toBeCalledTimes(2);
136+
expect(msg?.status).toEqual('sent');
137+
expect(mockLog.log).toBeCalledTimes(1);
138+
expect(mockLog.log).toBeCalledWith('SMS sent', {
139+
sid: 'foo',
140+
status: 'sent',
141+
});
142+
expect(mockMetrics.increment).toBeCalledTimes(1);
143+
expect(mockMetrics.increment).toBeCalledWith('sms.send.sent');
144+
});
145+
146+
it('Retries but eventually fails if send rate limit exceeded.', async () => {
147+
const mockError = new Error();
148+
(mockError as any).code = TwilioErrorCodes.SMS_SEND_RATE_LIMIT_EXCEEDED;
149+
150+
mockTwilioSmsClient.messages.create.mockRejectedValue(mockError);
151+
const body = 'test success';
152+
await expect(
153+
manager.sendSMS({
154+
to,
155+
body,
156+
})
157+
).rejects.toThrow(
158+
'Too many SMS are currently being sent. Try again later.'
159+
);
160+
161+
expect(mockLog.log).toBeCalledTimes(0);
162+
expect(mockMetrics.increment).toBeCalledTimes(1);
163+
expect(mockTwilioSmsClient.messages.create).toBeCalledTimes(3); // initial call + config.maxRetries.
164+
});
165+
108166
it('Records failure', async () => {
109167
const boom = new Error('Boom');
110168
mockTwilioSmsClient.messages.create.mockRejectedValue(boom);
@@ -120,4 +178,13 @@ describe('SmsManager', () => {
120178
expect(mockLog.log).toBeCalledTimes(0);
121179
expect(mockMetrics.increment).toBeCalledTimes(1);
122180
});
181+
182+
it('Rotates numbers', () => {
183+
const number1 = manager.rotateFromNumber();
184+
const number2 = manager.rotateFromNumber();
185+
186+
expect(number1).toBeDefined();
187+
expect(number2).toBeDefined();
188+
expect(number1).not.toEqual(number2);
189+
});
123190
});

libs/accounts/recovery-phone/src/lib/sms.manager.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ import { StatsDService } from '@fxa/shared/metrics/statsd';
77
import { Inject, Injectable, LoggerService } from '@nestjs/common';
88
import { StatsD } from 'hot-shots';
99
import { Twilio } from 'twilio';
10+
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
1011
import { SmsManagerConfig } from './sms.manger.config';
1112
import { TwilioProvider } from './twilio.provider';
1213
import {
1314
RecoveryNumberInvalidFormatError,
1415
RecoveryNumberNotSupportedError,
16+
SmsSendRateLimitExceededError,
1517
TwilioErrorCodes,
1618
} from './recovery-phone.errors';
1719

1820
@Injectable()
1921
export class SmsManager {
22+
private currentPhoneNumber = 0;
23+
2024
constructor(
2125
@Inject(TwilioProvider) private readonly client: Twilio,
2226
@Inject(StatsDService) private readonly metrics: StatsD,
@@ -56,12 +60,20 @@ export class SmsManager {
5660
}
5761
}
5862

63+
return await this._sendSMS(to, body, uid, 0);
64+
}
65+
66+
private async _sendSMS(
67+
to: string,
68+
body: string,
69+
uid: string | undefined,
70+
retryCount: number
71+
): Promise<MessageInstance> {
72+
const from = this.rotateFromNumber();
5973
try {
6074
const msg = await this.client.messages.create({
6175
to,
62-
// TODO: Should this just be passed in every time or set from config?
63-
// Will we always send from the same number?
64-
from: this.config.from,
76+
from,
6577
body,
6678
});
6779
// Typically the message will be in queued status. The following metric and log
@@ -73,12 +85,31 @@ export class SmsManager {
7385
});
7486
return msg;
7587
} catch (err) {
88+
if (err.code === TwilioErrorCodes.SMS_SEND_RATE_LIMIT_EXCEEDED) {
89+
if (retryCount < this.config.maxRetries) {
90+
// Exp back off and try again
91+
await new Promise((r) =>
92+
setTimeout(r, Math.pow(2, retryCount++) * 1000)
93+
);
94+
return await this._sendSMS(to, body, uid, retryCount);
95+
}
96+
}
97+
7698
this.metrics.increment('sms.send.error');
7799

78100
if (err.code === TwilioErrorCodes.INVALID_TO_PHONE_NUMBER) {
79101
throw new RecoveryNumberInvalidFormatError(uid || '', to, err);
80102
}
103+
if (err.code === TwilioErrorCodes.SMS_SEND_RATE_LIMIT_EXCEEDED) {
104+
throw new SmsSendRateLimitExceededError(uid || '', to, from, err);
105+
}
81106
throw err;
82107
}
83108
}
109+
110+
public rotateFromNumber() {
111+
return this.config.from[
112+
this.currentPhoneNumber++ % this.config.from.length
113+
];
114+
}
84115
}

libs/accounts/recovery-phone/src/lib/sms.manger.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ export class SmsManagerConfig {
99
* The number that SMS are sent from. This should be our number.
1010
* */
1111
@IsString()
12-
public readonly from!: string;
12+
public readonly from!: string[];
1313

1414
@IsNumber()
1515
public readonly maxMessageLength!: number;
1616

17+
@IsNumber()
18+
public readonly maxRetries!: number;
19+
1720
@IsArray()
1821
public readonly validNumberPrefixes!: Array<string>;
1922
}

packages/fxa-auth-server/config/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2168,10 +2168,10 @@ const convictConf = convict({
21682168
redis: {},
21692169
sms: {
21702170
from: {
2171-
default: '15005550006',
2171+
default: ['15005550006'],
21722172
doc: 'The twilio number messages are sent from.',
21732173
env: 'RECOVERY_PHONE__SMS__FROM',
2174-
format: String,
2174+
format: Array,
21752175
},
21762176
maxMessageLength: {
21772177
default: 60,
@@ -2185,6 +2185,12 @@ const convictConf = convict({
21852185
env: 'RECOVERY_PHONE__SMS__VALID_NUMBER_PREFIXES',
21862186
format: Array,
21872187
},
2188+
maxRetries: {
2189+
default: 3,
2190+
doc: 'Number of times an sms message can be retried in the event a sms sending rate limit is encountered. Note that we use an exponential back off here. e.g. 3 would mean up to a 1s, 2s and then 4s retry.',
2191+
env: 'RECOVERY_PHONE__SMS__MAX_RETRIES',
2192+
format: Number,
2193+
},
21882194
},
21892195
},
21902196
twilio: {

packages/fxa-auth-server/lib/error.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,15 @@ AppError.recoveryPhoneNumberDoesNotExist = () => {
594594
});
595595
};
596596

597+
AppError.smsSendRateLimitExceeded = () => {
598+
return new AppError({
599+
code: 429,
600+
error: 'Too many requests',
601+
errno: ERRNO.SMS_SEND_RATE_LIMIT_EXCEEDED,
602+
message: 'Client has sent too many requests',
603+
});
604+
};
605+
597606
AppError.invalidRegion = (region) => {
598607
return new AppError(
599608
{

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
RecoveryNumberInvalidFormatError,
99
RecoveryNumberAlreadyExistsError,
1010
RecoveryNumberNotExistsError,
11+
SmsSendRateLimitExceededError,
1112
} from '@fxa/accounts/recovery-phone';
1213
import {
1314
AccountManager,
@@ -61,6 +62,10 @@ class RecoveryPhoneHandler {
6162
throw AppError.recoveryPhoneNumberDoesNotExist();
6263
}
6364

65+
if (error instanceof SmsSendRateLimitExceededError) {
66+
throw AppError.smsSendRateLimitExceeded();
67+
}
68+
6469
throw AppError.backendServiceFailure(
6570
'RecoveryPhoneService',
6671
'sendCode',
@@ -113,6 +118,10 @@ class RecoveryPhoneHandler {
113118
throw AppError.invalidPhoneNumber();
114119
}
115120

121+
if (error instanceof SmsSendRateLimitExceededError) {
122+
throw AppError.smsSendRateLimitExceeded();
123+
}
124+
116125
throw AppError.backendServiceFailure(
117126
'RecoveryPhoneService',
118127
'setupPhoneNumber',

packages/fxa-auth-server/test/local/routes/recovery-phone.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const assert = { ...sinon.assert, ...chai.assert };
1111
const { recoveryPhoneRoutes } = require('../../../lib/routes/recovery-phone');
1212
import {
1313
RecoveryNumberNotSupportedError,
14+
SmsSendRateLimitExceededError,
1415
RecoveryPhoneService,
1516
} from '@fxa/accounts/recovery-phone';
1617

@@ -203,6 +204,25 @@ describe('/recovery_phone', () => {
203204
assert.equal(mockGlean.twoStepAuthPhoneCode.sendError.callCount, 1);
204205
});
205206

207+
it('indicates too many requests when sms rate limit is exceeded', async () => {
208+
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(
209+
Promise.reject(
210+
new SmsSendRateLimitExceededError(uid, phoneNumber, '+495550005555')
211+
)
212+
);
213+
214+
const promise = makeRequest({
215+
method: 'POST',
216+
path: '/recovery_phone/create',
217+
credentials: { uid, email },
218+
payload: { phoneNumber: '+495550005555' },
219+
});
220+
221+
await assert.isRejected(promise, 'Client has sent too many requests');
222+
assert.equal(mockGlean.twoStepAuthPhoneCode.sent.callCount, 0);
223+
assert.equal(mockGlean.twoStepAuthPhoneCode.sendError.callCount, 1);
224+
});
225+
206226
it('handles unexpected backend error', async () => {
207227
mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(
208228
Promise.reject(new Error('BOOM'))

packages/fxa-shared/lib/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export const AUTH_SERVER_ERRNOS = {
131131
RECOVERY_PHONE_NUMBER_ALREADY_EXISTS: 214,
132132
RECOVERY_PHONE_NUMBER_DOES_NOT_EXIST: 215,
133133

134+
SMS_SEND_RATE_LIMIT_EXCEEDED: 216,
135+
134136
INTERNAL_VALIDATION_ERROR: 998,
135137
UNEXPECTED_ERROR: 999,
136138
};

0 commit comments

Comments
 (0)