diff --git a/src/http.ts b/src/http.ts index c715405d..20a3b82b 100644 --- a/src/http.ts +++ b/src/http.ts @@ -107,7 +107,7 @@ export async function doHttpRequest( if (typeof (errBody) === "object" && 'errcode' in errBody) { const redactedBody = respIsBuffer ? '' : redactObjectForLogging(errBody); LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody); - throw new MatrixError(errBody, response.statusCode); + throw new MatrixError(errBody, response.statusCode, response.headers); } // Don't log the body unless we're in debug mode. They can be large. diff --git a/src/models/MatrixError.ts b/src/models/MatrixError.ts index c346f020..8e814255 100644 --- a/src/models/MatrixError.ts +++ b/src/models/MatrixError.ts @@ -1,8 +1,26 @@ +import { LogService } from "../logging/LogService"; + /** * Represents an HTTP error from the Matrix server. * @category Error handling */ export class MatrixError extends Error { + /** + * Parse a Retry-After header into a number of milliseconds. + * @see https://www.rfc-editor.org/rfc/rfc9110#field.retry-after + * @param header The value of a Retry-After header. + * @throws If the date could not be parsed. + */ + static parseRetryAfterHeader(header: string): number { + // First try to parse as seconds + const retryAfterSeconds = parseInt(header, 10); + if (!Number.isNaN(retryAfterSeconds)) { + return retryAfterSeconds * 1000; + } + const retryAfterDate = new Date(header); + return retryAfterDate.getTime() - Date.now(); + } + /** * The Matrix error code */ @@ -23,11 +41,23 @@ export class MatrixError extends Error { * @param body The error body. * @param statusCode The HTTP status code. */ - constructor(public readonly body: { errcode: string, error: string, retry_after_ms?: number }, public readonly statusCode: number) { + constructor(public readonly body: { errcode: string, error: string, retry_after_ms?: number }, public readonly statusCode: number, headers: Record) { super(); this.errcode = body.errcode; this.error = body.error; - this.retryAfterMs = body.retry_after_ms; + const retryAfterHeader = headers['retry-after']; + if (this.statusCode === 429 && retryAfterHeader) { + try { + this.retryAfterMs = MatrixError.parseRetryAfterHeader(retryAfterHeader); + } catch (ex) { + // Could not parse...skip handling for now. + LogService.warn("MatrixError", "Could not parse Retry-After header from request.", ex); + } + } + // Fall back to the deprecated retry_after_ms property. + if (!this.retryAfterMs && body.retry_after_ms) { + this.retryAfterMs = body.retry_after_ms; + } } /** diff --git a/test/models/MatrixErrorTest.ts b/test/models/MatrixErrorTest.ts new file mode 100644 index 00000000..adef19d5 --- /dev/null +++ b/test/models/MatrixErrorTest.ts @@ -0,0 +1,39 @@ +import { MatrixError } from "../../src"; + +describe("MatrixError", () => { + it("should construct a basic MatrixError", () => { + const err = new MatrixError({ 'errcode': 'M_TEST', "error": 'Test fail' }, 500, {}); + expect(err.message).toBe('M_TEST: Test fail'); + expect(err.retryAfterMs).toBeUndefined(); + }); + it("should handle a 429 without a retry duration", () => { + const err = new MatrixError({ 'errcode': 'M_TEST', "error": 'Test fail' }, 429, {}); + expect(err.message).toBe('M_TEST: Test fail'); + expect(err.retryAfterMs).toBeUndefined(); + }); + it("should handle a 429 with a Retry-After header (time)", () => { + // Should ignore the deprecated field. + const err = new MatrixError({ 'errcode': 'M_TEST', "error": 'Test fail', "retry_after_ms": 5 }, 429, { + 'retry-after': '10', + }); + expect(err.message).toBe('M_TEST: Test fail'); + expect(err.retryAfterMs).toEqual(10000); + }); + it("should handle a 429 with a Retry-After header (date)", () => { + jest + .spyOn(global.Date, 'now') + .mockImplementationOnce(() => new Date('Wed, 20 Mar 2024 10:18:16 UTC').valueOf()); + + // Should ignore the deprecated field. + const err = new MatrixError({ 'errcode': 'M_TEST', "error": 'Test fail', "retry_after_ms": 5 }, 429, { + 'retry-after': 'Wed, 20 Mar 2024 10:18:26 UTC', + }); + expect(err.message).toBe('M_TEST: Test fail'); + expect(err.retryAfterMs).toEqual(10000); + }); + it("should handle a 429 with a Retry-After header (date)", () => { + const err = new MatrixError({ 'errcode': 'M_TEST', "error": 'Test fail', "retry_after_ms": 5 }, 429, {}); + expect(err.message).toBe('M_TEST: Test fail'); + expect(err.retryAfterMs).toEqual(5); + }); +});