Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add explicit handling for rate-limit errors #1033

Merged
merged 8 commits into from
May 3, 2024
8 changes: 5 additions & 3 deletions src/common/exceptions/bad-request.exception.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export class BadRequestException extends Error {
readonly status: number = 400;
readonly name: string = 'BadRequestException';
import { RequestException } from '../interfaces/request-exception.interface';

export class BadRequestException extends Error implements RequestException {
readonly status = 400;
readonly name = 'BadRequestException';
readonly message: string = 'Bad request';
readonly code?: string;
readonly errors?: unknown[];
Expand Down
6 changes: 4 additions & 2 deletions src/common/exceptions/generic-server.exception.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export class GenericServerException extends Error {
readonly name: string = 'GenericServerException';
import { RequestException } from '../interfaces/request-exception.interface';

export class GenericServerException extends Error implements RequestException {
readonly name = 'GenericServerException';
readonly message: string = 'The request could not be completed.';

constructor(
Expand Down
1 change: 1 addition & 0 deletions src/common/exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './bad-request.exception';
export * from './no-api-key-provided.exception';
export * from './not-found.exception';
export * from './oauth.exception';
export * from './rate-limit-exceeded.exception';
export * from './signature-verification.exception';
export * from './unauthorized.exception';
export * from './unprocessable-entity.exception';
6 changes: 3 additions & 3 deletions src/common/exceptions/no-api-key-provided.exception.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export class NoApiKeyProvidedException extends Error {
readonly status: number = 500;
readonly name: string = 'NoApiKeyProvidedException';
readonly message: string =
readonly status = 500;
readonly name = 'NoApiKeyProvidedException';
readonly message =
`Missing API key. Pass it to the constructor (new WorkOS("sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU")) ` +
`or define it in the WORKOS_API_KEY environment variable.`;
}
8 changes: 5 additions & 3 deletions src/common/exceptions/not-found.exception.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export class NotFoundException extends Error {
readonly status: number = 404;
readonly name: string = 'NotFoundException';
import { RequestException } from '../interfaces/request-exception.interface';

export class NotFoundException extends Error implements RequestException {
readonly status = 404;
readonly name = 'NotFoundException';
readonly message: string;
readonly code?: string;
readonly requestID: string;
Expand Down
6 changes: 4 additions & 2 deletions src/common/exceptions/oauth.exception.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export class OauthException extends Error {
readonly name: string = 'OauthException';
import { RequestException } from '../interfaces/request-exception.interface';

export class OauthException extends Error implements RequestException {
readonly name = 'OauthException';

constructor(
readonly status: number,
Expand Down
20 changes: 20 additions & 0 deletions src/common/exceptions/rate-limit-exceeded.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { RequestException } from '../interfaces/request-exception.interface';

export class RateLimitExceededException
extends Error
implements RequestException
{
readonly name = 'RateLimitExceededException';
readonly status = 429;

constructor(
readonly message: string,
readonly requestID: string,
/**
* The number of seconds to wait before retrying the request.
*/
readonly retryAfter: number | null,
) {
super();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export class SignatureVerificationException extends Error {
readonly name: string = 'SignatureVerificationException';
readonly name = 'SignatureVerificationException';

constructor(message: string) {
super(message || 'Signature verification failed.');
Expand Down
8 changes: 5 additions & 3 deletions src/common/exceptions/unauthorized.exception.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export class UnauthorizedException extends Error {
readonly status: number = 401;
readonly name: string = 'UnauthorizedException';
import { RequestException } from '../interfaces/request-exception.interface';

export class UnauthorizedException extends Error implements RequestException {
readonly status = 401;
readonly name = 'UnauthorizedException';
readonly message: string;

constructor(readonly requestID: string) {
Expand Down
12 changes: 8 additions & 4 deletions src/common/exceptions/unprocessable-entity.exception.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import pluralize from 'pluralize';

import { UnprocessableEntityError } from '../interfaces';

export class UnprocessableEntityException extends Error {
readonly status: number = 422;
readonly name: string = 'UnprocessableEntityException';
import { RequestException } from '../interfaces/request-exception.interface';

export class UnprocessableEntityException
extends Error
implements RequestException
{
readonly status = 422;
readonly name = 'UnprocessableEntityException';
readonly message: string = 'Unprocessable entity';
readonly code?: string;
readonly requestID: string;
Expand Down
5 changes: 5 additions & 0 deletions src/common/interfaces/request-exception.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface RequestException {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my initial pass I realized the new RateLimitExceededException was missing status. It seemed worth having something like this in order for all exception classes to be roughly shaped the same.

Arguable we should have a base-class instead, but I opted for an interface for now since it only adds an additional compile-time check, and we can decide later.

readonly status: number;
readonly message: string;
readonly requestID: string;
}
26 changes: 26 additions & 0 deletions src/workos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
OauthException,
} from './common/exceptions';
import { WorkOS } from './workos';
import { RateLimitExceededException } from './common/exceptions/rate-limit-exceeded.exception';

describe('WorkOS', () => {
beforeEach(() => fetch.resetMocks());
Expand Down Expand Up @@ -198,6 +199,7 @@ describe('WorkOS', () => {
);
});
});

describe('when the api responds with a 400 and an error/error_description', () => {
it('throws an OauthException', async () => {
fetchOnce(
Expand All @@ -222,6 +224,30 @@ describe('WorkOS', () => {
});
});

describe('when the api responses with a 429', () => {
it('throws a RateLimitExceededException', async () => {
fetchOnce(
{
message: 'Too many requests',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this verbatim what we return from the API? I'd like there to be more information in here, like a link to the rate limiting docs or a callout to reach out on support@ if they want to discuss higher rate limits.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, and agreed that we should have more information in the message. Separately, I can look into updating that, adding the additional details you mentioned, so that all SDK's (or no SDK at all) will have the extra context.

},
{
status: 429,
headers: { 'X-Request-ID': 'a-request-id', 'Retry-After': '10' },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 10 seconds the default or is that just for this test? If it's the default returned by the API then that feels way too high.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a test value. IIRC, the actual Retry-After is generally larger, and varies by endpoint.

},
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

await expect(workos.get('/path')).rejects.toStrictEqual(
new RateLimitExceededException(
'Too many requests',
'a-request-id',
10,
),
);
});
});

describe('when the entity is null', () => {
it('sends a null body', async () => {
fetchOnce();
Expand Down
10 changes: 10 additions & 0 deletions src/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UnauthorizedException,
UnprocessableEntityException,
OauthException,
RateLimitExceededException,
} from './common/exceptions';
import {
GetOptions,
Expand Down Expand Up @@ -214,6 +215,15 @@ export class WorkOS {
requestID,
});
}
case 429: {
const retryAfter = headers.get('Retry-After');

throw new RateLimitExceededException(
data.message,
requestID,
retryAfter ? Number(retryAfter) : null,
);
}
Comment on lines +218 to +226
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a breaking change?

Existing users may be doing an error instanceof GenericServerException when attempting to handle rate limit errors, which would no longer work with the introduction of this new exception.

Copy link
Contributor Author

@mthadley mthadley Apr 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, one option would be to make GenericServerException the actual base-class that the other exceptions extend from. It can enforce that rough shape we want all of them to have, and would mean this is no longer a potential breaking-change.

Though I'd like to hear opinions before I make that change.

EDIT: It might be a breaking change either way since even if the base class stays the same, the name will not, and folks may be relying on that, too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strongly prefer having GenericServerException be the base-class for inheritance to make this not be a breaking change. We want to generally shy away from releasing major versions too frequently.

While someone out there might be relying on the name, I feel that that's a bit of an anti-pattern over checking instanceof. I'm comfortable with releasing this as a minor version instead of a major.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the minimal change of only having the new exception inherit from GenericServerException, though a large change could be to have all of the requests also inherit (and left a note about it): 4af4a79.

default: {
if (error || errorDescription) {
throw new OauthException(
Expand Down