@oneuptime/common 12.0.9 → 12.0.11
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/Models/DatabaseModels/NetworkDevice.ts +44 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
- package/Models/DatabaseModels/RunnerJob.ts +17 -2
- package/Server/API/LlmProviderAPI.ts +8 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
- package/Server/Services/NetworkDeviceLinkService.ts +338 -2
- package/Server/Services/NetworkDeviceService.ts +265 -12
- package/Server/Services/RunnerJobService.ts +24 -1
- package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
- package/Server/Types/Database/JSONColumnQuery.ts +42 -4
- package/Server/Utils/LLM/LLMService.ts +108 -19
- package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
- package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
- package/Server/Utils/SSRFProtection.ts +5 -4
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
- package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
- package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
- package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
- package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
- package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
- package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
- package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
- package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
- package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
- package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
- package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
- package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
- package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
- package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
- package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
- package/Tests/Types/API/URLTelScheme.test.ts +261 -0
- package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
- package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
- package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
- package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
- package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
- package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
- package/Tests/UI/Components/Button.test.tsx +279 -0
- package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
- package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
- package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
- package/Tests/UI/Components/Icon.test.tsx +57 -0
- package/Tests/UI/Components/List.test.tsx +20 -3
- package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
- package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
- package/Tests/UI/Components/Pagination.test.tsx +721 -146
- package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
- package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
- package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
- package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
- package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
- package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
- package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
- package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
- package/Types/API/Protocol.ts +9 -0
- package/Types/API/URL.ts +286 -20
- package/Types/Billing/PayAsYouGoPricing.ts +47 -0
- package/Types/CustomField/CustomFieldType.ts +28 -0
- package/Types/Monitor/MonitorType.ts +11 -0
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
- package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
- package/Types/Runbook/RunbookStepType.ts +23 -0
- package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
- package/UI/Components/Button/Button.tsx +14 -22
- package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
- package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
- package/UI/Components/Charts/Line/LineChart.tsx +4 -2
- package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
- package/UI/Components/Dictionary/Dictionary.tsx +4 -39
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
- package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
- package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
- package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
- package/UI/Components/Pagination/Pagination.tsx +373 -328
- package/UI/Components/Pagination/PaginationUtil.ts +263 -0
- package/UI/Components/Pagination/Screenshots/README.md +15 -0
- package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
- package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
- package/UI/Components/TextArea/TextArea.tsx +3 -0
- package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
- package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
- package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
- package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
- package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
- package/Utils/API.ts +243 -36
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
- package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Server/API/LlmProviderAPI.js +9 -1
- package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/RunnerJobService.js +20 -2
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
- package/build/dist/Server/Utils/SSRFProtection.js +5 -4
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Types/API/Protocol.js +9 -0
- package/build/dist/Types/API/Protocol.js.map +1 -1
- package/build/dist/Types/API/URL.js +241 -18
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
- package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
- package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +10 -0
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
- package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
- package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
- package/build/dist/UI/Components/Button/Button.js +10 -16
- package/build/dist/UI/Components/Button/Button.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
- package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
- package/build/dist/Utils/API.js +175 -34
- package/build/dist/Utils/API.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
|
|
2
|
+
import HTTPMethod from "../../Types/API/HTTPMethod";
|
|
3
|
+
import HTTPResponse from "../../Types/API/HTTPResponse";
|
|
4
|
+
import Protocol from "../../Types/API/Protocol";
|
|
5
|
+
import Route from "../../Types/API/Route";
|
|
6
|
+
import URL from "../../Types/API/URL";
|
|
7
|
+
import Dictionary from "../../Types/Dictionary";
|
|
8
|
+
import APIException from "../../Types/Exception/ApiException";
|
|
9
|
+
import { JSONObject } from "../../Types/JSON";
|
|
10
|
+
import Sleep from "../../Types/Sleep";
|
|
11
|
+
import API, { RequestOptions, RequestOutcome } from "../../Utils/API";
|
|
12
|
+
import {
|
|
13
|
+
afterEach,
|
|
14
|
+
beforeEach,
|
|
15
|
+
describe,
|
|
16
|
+
expect,
|
|
17
|
+
jest,
|
|
18
|
+
test,
|
|
19
|
+
} from "@jest/globals";
|
|
20
|
+
import axios, {
|
|
21
|
+
AxiosError,
|
|
22
|
+
AxiosHeaders,
|
|
23
|
+
AxiosRequestConfig,
|
|
24
|
+
AxiosResponse,
|
|
25
|
+
AxiosStatic,
|
|
26
|
+
} from "axios";
|
|
27
|
+
|
|
28
|
+
jest.mock("axios", () => {
|
|
29
|
+
// Keep the real helpers (axios.isAxiosError) and mock only the callable.
|
|
30
|
+
return Object.assign(jest.fn(), jest.requireActual("axios"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const mockedAxios: jest.MockedFunction<AxiosStatic> =
|
|
34
|
+
axios as unknown as jest.MockedFunction<AxiosStatic>;
|
|
35
|
+
|
|
36
|
+
const TEST_URL: URL = new URL(
|
|
37
|
+
Protocol.HTTPS,
|
|
38
|
+
"provider.example.com",
|
|
39
|
+
new Route("/v1/chat/completions"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* Simulated wall clock. Both the transport and the backoff sleep advance it,
|
|
44
|
+
* so budget assertions are exact instead of racing real time.
|
|
45
|
+
*/
|
|
46
|
+
let nowInMs: number = 0;
|
|
47
|
+
|
|
48
|
+
/** Milliseconds passed to every Sleep.sleep call, in order. */
|
|
49
|
+
let sleptForInMs: Array<number> = [];
|
|
50
|
+
|
|
51
|
+
function createAxiosError(data: {
|
|
52
|
+
status?: number | undefined;
|
|
53
|
+
code?: string | undefined;
|
|
54
|
+
headers?: Dictionary<string> | undefined;
|
|
55
|
+
message?: string | undefined;
|
|
56
|
+
}): AxiosError {
|
|
57
|
+
const error: AxiosError = new Error(
|
|
58
|
+
data.message ?? "request failed",
|
|
59
|
+
) as AxiosError;
|
|
60
|
+
|
|
61
|
+
error.isAxiosError = true;
|
|
62
|
+
error.name = "AxiosError";
|
|
63
|
+
error.config = { headers: new AxiosHeaders() };
|
|
64
|
+
error.toJSON = () => {
|
|
65
|
+
return {};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (data.code) {
|
|
69
|
+
error.code = data.code;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (data.status !== undefined) {
|
|
73
|
+
error.response = {
|
|
74
|
+
status: data.status,
|
|
75
|
+
statusText: "",
|
|
76
|
+
data: { error: { message: "provider said no" } },
|
|
77
|
+
headers: data.headers ?? {},
|
|
78
|
+
config: { headers: new AxiosHeaders() },
|
|
79
|
+
} as unknown as AxiosResponse;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createAxiosSuccess(data: JSONObject = { ok: true }): AxiosResponse {
|
|
86
|
+
return {
|
|
87
|
+
data: data,
|
|
88
|
+
status: 200,
|
|
89
|
+
statusText: "OK",
|
|
90
|
+
headers: {},
|
|
91
|
+
config: { headers: new AxiosHeaders() },
|
|
92
|
+
} as unknown as AxiosResponse;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Make every attempt fail with `error`, taking `elapsedPerAttemptInMs` of
|
|
97
|
+
* simulated wall clock each time.
|
|
98
|
+
*/
|
|
99
|
+
function alwaysFailWith(
|
|
100
|
+
error: AxiosError,
|
|
101
|
+
elapsedPerAttemptInMs: number = 0,
|
|
102
|
+
): void {
|
|
103
|
+
mockedAxios.mockImplementation(async () => {
|
|
104
|
+
nowInMs += elapsedPerAttemptInMs;
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function post(
|
|
110
|
+
options: RequestOptions,
|
|
111
|
+
): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> {
|
|
112
|
+
return API.post<JSONObject>({
|
|
113
|
+
url: URL.fromString(TEST_URL.toString()),
|
|
114
|
+
data: { hello: "world" },
|
|
115
|
+
options: options,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
nowInMs = 1_700_000_000_000;
|
|
121
|
+
sleptForInMs = [];
|
|
122
|
+
|
|
123
|
+
jest.spyOn(Date, "now").mockImplementation(() => {
|
|
124
|
+
return nowInMs;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
jest.spyOn(Sleep, "sleep").mockImplementation(async (ms: number) => {
|
|
128
|
+
sleptForInMs.push(ms);
|
|
129
|
+
nowInMs += ms;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Deterministic jitter unless a test says otherwise: the midpoint.
|
|
133
|
+
jest.spyOn(Math, "random").mockReturnValue(0.5);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
mockedAxios.mockReset();
|
|
138
|
+
jest.restoreAllMocks();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("API.isRetryableError", () => {
|
|
142
|
+
interface RetryableCase {
|
|
143
|
+
name: string;
|
|
144
|
+
error: unknown;
|
|
145
|
+
isRetryable: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cases: Array<RetryableCase> = [
|
|
149
|
+
{
|
|
150
|
+
name: "a timeout that never produced a response",
|
|
151
|
+
error: createAxiosError({
|
|
152
|
+
code: "ECONNABORTED",
|
|
153
|
+
message: "timeout of 120000ms exceeded",
|
|
154
|
+
}),
|
|
155
|
+
isRetryable: true,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "a refused connection",
|
|
159
|
+
error: createAxiosError({ code: "ECONNREFUSED" }),
|
|
160
|
+
isRetryable: true,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "a reset connection",
|
|
164
|
+
error: createAxiosError({ code: "ECONNRESET" }),
|
|
165
|
+
isRetryable: true,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "a DNS failure",
|
|
169
|
+
error: createAxiosError({ code: "ENOTFOUND" }),
|
|
170
|
+
isRetryable: true,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "HTTP 500",
|
|
174
|
+
error: createAxiosError({ status: 500 }),
|
|
175
|
+
isRetryable: true,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "HTTP 502",
|
|
179
|
+
error: createAxiosError({ status: 502 }),
|
|
180
|
+
isRetryable: true,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "HTTP 503",
|
|
184
|
+
error: createAxiosError({ status: 503 }),
|
|
185
|
+
isRetryable: true,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "HTTP 504",
|
|
189
|
+
error: createAxiosError({ status: 504 }),
|
|
190
|
+
isRetryable: true,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "HTTP 408 Request Timeout",
|
|
194
|
+
error: createAxiosError({ status: 408 }),
|
|
195
|
+
isRetryable: true,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "HTTP 425 Too Early",
|
|
199
|
+
error: createAxiosError({ status: 425 }),
|
|
200
|
+
isRetryable: true,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "HTTP 429 Too Many Requests",
|
|
204
|
+
error: createAxiosError({ status: 429 }),
|
|
205
|
+
isRetryable: true,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "HTTP 400 Bad Request",
|
|
209
|
+
error: createAxiosError({ status: 400 }),
|
|
210
|
+
isRetryable: false,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "HTTP 401 Unauthorized",
|
|
214
|
+
error: createAxiosError({ status: 401 }),
|
|
215
|
+
isRetryable: false,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "HTTP 403 Forbidden",
|
|
219
|
+
error: createAxiosError({ status: 403 }),
|
|
220
|
+
isRetryable: false,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "HTTP 404 Not Found",
|
|
224
|
+
error: createAxiosError({ status: 404 }),
|
|
225
|
+
isRetryable: false,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "HTTP 422 Unprocessable Entity",
|
|
229
|
+
error: createAxiosError({ status: 422 }),
|
|
230
|
+
isRetryable: false,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "a redirect surfaced because redirects are refused",
|
|
234
|
+
error: createAxiosError({ status: 302 }),
|
|
235
|
+
isRetryable: false,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "a request the caller cancelled",
|
|
239
|
+
error: createAxiosError({ code: "ERR_CANCELED" }),
|
|
240
|
+
isRetryable: false,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "a non-axios error thrown while building the request",
|
|
244
|
+
error: new Error("cannot serialize body"),
|
|
245
|
+
isRetryable: false,
|
|
246
|
+
},
|
|
247
|
+
{ name: "a non-error value", error: "boom", isRetryable: false },
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
test.each(cases)(
|
|
251
|
+
"$name is retryable: $isRetryable",
|
|
252
|
+
({ error, isRetryable }: RetryableCase) => {
|
|
253
|
+
expect(API.isRetryableError(error)).toBe(isRetryable);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("attempt counting", () => {
|
|
259
|
+
test("stops as soon as an attempt succeeds", async () => {
|
|
260
|
+
mockedAxios
|
|
261
|
+
.mockRejectedValueOnce(createAxiosError({ status: 503 }))
|
|
262
|
+
.mockRejectedValueOnce(createAxiosError({ status: 503 }))
|
|
263
|
+
.mockResolvedValueOnce(createAxiosSuccess({ answer: 42 }));
|
|
264
|
+
|
|
265
|
+
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await post({
|
|
266
|
+
retries: 9,
|
|
267
|
+
exponentialBackoff: true,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(mockedAxios).toHaveBeenCalledTimes(3);
|
|
271
|
+
expect(response).toBeInstanceOf(HTTPResponse);
|
|
272
|
+
expect((response as HTTPResponse<JSONObject>).data).toEqual({ answer: 42 });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a 10-attempt budget makes exactly 10 attempts before giving up", async () => {
|
|
276
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
277
|
+
|
|
278
|
+
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await post({
|
|
279
|
+
retries: 9,
|
|
280
|
+
exponentialBackoff: true,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
284
|
+
expect(response).toBeInstanceOf(HTTPErrorResponse);
|
|
285
|
+
expect((response as HTTPErrorResponse).statusCode).toBe(503);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("retries: 0 sends the request exactly once", async () => {
|
|
289
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
290
|
+
|
|
291
|
+
await post({ retries: 0, exponentialBackoff: true });
|
|
292
|
+
|
|
293
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(sleptForInMs).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("no retry option at all still sends the request once", async () => {
|
|
298
|
+
mockedAxios.mockResolvedValueOnce(createAxiosSuccess());
|
|
299
|
+
|
|
300
|
+
await post({});
|
|
301
|
+
|
|
302
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a negative retry budget sends the request rather than reporting no response", async () => {
|
|
306
|
+
mockedAxios.mockResolvedValueOnce(createAxiosSuccess({ answer: 1 }));
|
|
307
|
+
|
|
308
|
+
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await post({
|
|
309
|
+
retries: -5,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
313
|
+
expect(response).toBeInstanceOf(HTTPResponse);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("reports every attempt through onRequestComplete on success", async () => {
|
|
317
|
+
mockedAxios
|
|
318
|
+
.mockRejectedValueOnce(createAxiosError({ status: 503 }))
|
|
319
|
+
.mockResolvedValueOnce(createAxiosSuccess());
|
|
320
|
+
|
|
321
|
+
let outcome: RequestOutcome | undefined = undefined;
|
|
322
|
+
|
|
323
|
+
await post({
|
|
324
|
+
retries: 9,
|
|
325
|
+
exponentialBackoff: true,
|
|
326
|
+
onRequestComplete: (result: RequestOutcome) => {
|
|
327
|
+
outcome = result;
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(outcome).toBeDefined();
|
|
332
|
+
expect(outcome!.attempts).toBe(2);
|
|
333
|
+
expect(outcome!.statusCode).toBe(200);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("reports every attempt through onRequestComplete on failure", async () => {
|
|
337
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
338
|
+
|
|
339
|
+
let outcome: RequestOutcome | undefined = undefined;
|
|
340
|
+
|
|
341
|
+
await post({
|
|
342
|
+
retries: 3,
|
|
343
|
+
exponentialBackoff: true,
|
|
344
|
+
onRequestComplete: (result: RequestOutcome) => {
|
|
345
|
+
outcome = result;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(outcome!.attempts).toBe(4);
|
|
350
|
+
expect(outcome!.statusCode).toBe(503);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("a request that never gets a response still throws after the full budget", async () => {
|
|
354
|
+
alwaysFailWith(
|
|
355
|
+
createAxiosError({
|
|
356
|
+
code: "ECONNABORTED",
|
|
357
|
+
message: "timeout of 120000ms exceeded",
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
await expect(
|
|
362
|
+
post({ retries: 2, exponentialBackoff: true }),
|
|
363
|
+
).rejects.toThrow(APIException);
|
|
364
|
+
expect(mockedAxios).toHaveBeenCalledTimes(3);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("exponential backoff", () => {
|
|
369
|
+
test("doubles the wait between attempts", async () => {
|
|
370
|
+
// Midpoint jitter (Math.random = 0.5) puts each wait at 75% of its base.
|
|
371
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
372
|
+
|
|
373
|
+
await post({ retries: 3, exponentialBackoff: true });
|
|
374
|
+
|
|
375
|
+
expect(sleptForInMs).toEqual([1500, 3000, 6000]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("never waits at all when backoff is off", async () => {
|
|
379
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
380
|
+
|
|
381
|
+
await post({ retries: 3 });
|
|
382
|
+
|
|
383
|
+
expect(mockedAxios).toHaveBeenCalledTimes(4);
|
|
384
|
+
expect(sleptForInMs).toEqual([]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("caps each wait at maxBackoffInMs instead of doubling without limit", async () => {
|
|
388
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
389
|
+
|
|
390
|
+
await post({ retries: 9, exponentialBackoff: true, maxBackoffInMs: 8000 });
|
|
391
|
+
|
|
392
|
+
/*
|
|
393
|
+
* The uncapped ladder would end at 2^9 = 512s for the last wait alone.
|
|
394
|
+
* Capped at 8s (6s after midpoint jitter) the whole ladder is under a
|
|
395
|
+
* minute.
|
|
396
|
+
*/
|
|
397
|
+
expect(sleptForInMs).toEqual([
|
|
398
|
+
1500, 3000, 6000, 6000, 6000, 6000, 6000, 6000, 6000,
|
|
399
|
+
]);
|
|
400
|
+
expect(
|
|
401
|
+
sleptForInMs.reduce((total: number, ms: number) => {
|
|
402
|
+
return total + ms;
|
|
403
|
+
}, 0),
|
|
404
|
+
).toBeLessThan(60_000);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("falls back to a generous default cap when the caller names none", async () => {
|
|
408
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
409
|
+
|
|
410
|
+
await post({ retries: 9, exponentialBackoff: true });
|
|
411
|
+
|
|
412
|
+
for (const ms of sleptForInMs) {
|
|
413
|
+
expect(ms).toBeLessThanOrEqual(API.DEFAULT_MAX_BACKOFF_IN_MS);
|
|
414
|
+
}
|
|
415
|
+
// The last waits are the ones the default cap actually bites on.
|
|
416
|
+
expect(sleptForInMs[sleptForInMs.length - 1]).toBe(
|
|
417
|
+
API.DEFAULT_MAX_BACKOFF_IN_MS / 2 + API.DEFAULT_MAX_BACKOFF_IN_MS / 2 / 2,
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the default cap leaves a short retry budget untouched", async () => {
|
|
422
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
423
|
+
|
|
424
|
+
await post({ retries: 3, exponentialBackoff: true });
|
|
425
|
+
|
|
426
|
+
// 2s, 4s, 8s bases — nowhere near the 60s default ceiling.
|
|
427
|
+
expect(sleptForInMs).toEqual([1500, 3000, 6000]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
interface JitterCase {
|
|
431
|
+
name: string;
|
|
432
|
+
random: number;
|
|
433
|
+
expectedFirstWaitInMs: number;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const jitterCases: Array<JitterCase> = [
|
|
437
|
+
{ name: "lowest", random: 0, expectedFirstWaitInMs: 1000 },
|
|
438
|
+
{ name: "midpoint", random: 0.5, expectedFirstWaitInMs: 1500 },
|
|
439
|
+
{ name: "highest", random: 0.999999, expectedFirstWaitInMs: 2000 },
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
test.each(jitterCases)(
|
|
443
|
+
"$name jitter keeps the first wait inside half the base and the full base",
|
|
444
|
+
async ({ random, expectedFirstWaitInMs }: JitterCase) => {
|
|
445
|
+
jest.spyOn(Math, "random").mockReturnValue(random);
|
|
446
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
447
|
+
|
|
448
|
+
await post({ retries: 1, exponentialBackoff: true });
|
|
449
|
+
|
|
450
|
+
expect(sleptForInMs).toEqual([expectedFirstWaitInMs]);
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
test("spreads concurrent callers rather than releasing them together", async () => {
|
|
455
|
+
// Real randomness for this one: the point is that the waits differ.
|
|
456
|
+
jest.spyOn(Math, "random").mockRestore();
|
|
457
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
458
|
+
|
|
459
|
+
const waitsByCaller: Array<number> = [];
|
|
460
|
+
|
|
461
|
+
for (let caller: number = 0; caller < 25; caller++) {
|
|
462
|
+
sleptForInMs = [];
|
|
463
|
+
await post({ retries: 1, exponentialBackoff: true });
|
|
464
|
+
waitsByCaller.push(sleptForInMs[0]!);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const wait of waitsByCaller) {
|
|
468
|
+
expect(wait).toBeGreaterThanOrEqual(1000);
|
|
469
|
+
expect(wait).toBeLessThanOrEqual(2000);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
expect(new Set(waitsByCaller).size).toBeGreaterThan(1);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("Retry-After", () => {
|
|
477
|
+
test("waits as long as the server asked instead of using the formula", async () => {
|
|
478
|
+
alwaysFailWith(
|
|
479
|
+
createAxiosError({ status: 429, headers: { "retry-after": "7" } }),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await post({
|
|
483
|
+
retries: 1,
|
|
484
|
+
exponentialBackoff: true,
|
|
485
|
+
maxBackoffInMs: 30_000,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(sleptForInMs).toEqual([7000]);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("is read case-insensitively", async () => {
|
|
492
|
+
alwaysFailWith(
|
|
493
|
+
createAxiosError({ status: 429, headers: { "Retry-After": "3" } }),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
await post({
|
|
497
|
+
retries: 1,
|
|
498
|
+
exponentialBackoff: true,
|
|
499
|
+
maxBackoffInMs: 30_000,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(sleptForInMs).toEqual([3000]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("is still capped, so the server cannot own the caller's clock", async () => {
|
|
506
|
+
alwaysFailWith(
|
|
507
|
+
createAxiosError({ status: 429, headers: { "retry-after": "3600" } }),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await post({ retries: 1, exponentialBackoff: true, maxBackoffInMs: 8000 });
|
|
511
|
+
|
|
512
|
+
expect(sleptForInMs).toEqual([8000]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("accepts the HTTP-date form", async () => {
|
|
516
|
+
const retryAt: string = new Date(nowInMs + 5000).toUTCString();
|
|
517
|
+
|
|
518
|
+
alwaysFailWith(
|
|
519
|
+
createAxiosError({ status: 503, headers: { "retry-after": retryAt } }),
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
await post({
|
|
523
|
+
retries: 1,
|
|
524
|
+
exponentialBackoff: true,
|
|
525
|
+
maxBackoffInMs: 30_000,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// toUTCString truncates to whole seconds, so allow the rounding.
|
|
529
|
+
expect(sleptForInMs).toHaveLength(1);
|
|
530
|
+
expect(sleptForInMs[0]).toBeGreaterThanOrEqual(4000);
|
|
531
|
+
expect(sleptForInMs[0]).toBeLessThanOrEqual(5000);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("treats a date already in the past as retry immediately", async () => {
|
|
535
|
+
const retryAt: string = new Date(nowInMs - 60_000).toUTCString();
|
|
536
|
+
|
|
537
|
+
alwaysFailWith(
|
|
538
|
+
createAxiosError({ status: 503, headers: { "retry-after": retryAt } }),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
await post({ retries: 1, exponentialBackoff: true });
|
|
542
|
+
|
|
543
|
+
expect(mockedAxios).toHaveBeenCalledTimes(2);
|
|
544
|
+
expect(sleptForInMs).toEqual([]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("ignores a value it cannot make sense of and backs off normally", async () => {
|
|
548
|
+
alwaysFailWith(
|
|
549
|
+
createAxiosError({
|
|
550
|
+
status: 503,
|
|
551
|
+
headers: { "retry-after": "soon-ish" },
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
await post({ retries: 1, exponentialBackoff: true });
|
|
556
|
+
|
|
557
|
+
expect(sleptForInMs).toEqual([1500]);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("ignores an empty header and backs off normally", async () => {
|
|
561
|
+
alwaysFailWith(
|
|
562
|
+
createAxiosError({ status: 503, headers: { "retry-after": " " } }),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
await post({ retries: 1, exponentialBackoff: true });
|
|
566
|
+
|
|
567
|
+
expect(sleptForInMs).toEqual([1500]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("applies even when exponential backoff is off", async () => {
|
|
571
|
+
alwaysFailWith(
|
|
572
|
+
createAxiosError({ status: 429, headers: { "retry-after": "2" } }),
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
await post({ retries: 1 });
|
|
576
|
+
|
|
577
|
+
expect(sleptForInMs).toEqual([2000]);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("retryOnlyOnRetryableErrors", () => {
|
|
582
|
+
test("spends nothing on a rejection that repeating cannot fix", async () => {
|
|
583
|
+
alwaysFailWith(createAxiosError({ status: 400 }));
|
|
584
|
+
|
|
585
|
+
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await post({
|
|
586
|
+
retries: 9,
|
|
587
|
+
exponentialBackoff: true,
|
|
588
|
+
retryOnlyOnRetryableErrors: true,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
592
|
+
expect(sleptForInMs).toEqual([]);
|
|
593
|
+
expect(response).toBeInstanceOf(HTTPErrorResponse);
|
|
594
|
+
expect((response as HTTPErrorResponse).statusCode).toBe(400);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("still spends the full budget on an overloaded server", async () => {
|
|
598
|
+
alwaysFailWith(createAxiosError({ status: 503 }));
|
|
599
|
+
|
|
600
|
+
await post({
|
|
601
|
+
retries: 9,
|
|
602
|
+
exponentialBackoff: true,
|
|
603
|
+
retryOnlyOnRetryableErrors: true,
|
|
604
|
+
maxBackoffInMs: 8000,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("still spends the full budget on a rate limit", async () => {
|
|
611
|
+
alwaysFailWith(createAxiosError({ status: 429 }));
|
|
612
|
+
|
|
613
|
+
await post({
|
|
614
|
+
retries: 9,
|
|
615
|
+
exponentialBackoff: true,
|
|
616
|
+
retryOnlyOnRetryableErrors: true,
|
|
617
|
+
maxBackoffInMs: 8000,
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("still spends the full budget on a timeout", async () => {
|
|
624
|
+
alwaysFailWith(
|
|
625
|
+
createAxiosError({
|
|
626
|
+
code: "ECONNABORTED",
|
|
627
|
+
message: "timeout of 120000ms exceeded",
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
await expect(
|
|
632
|
+
post({
|
|
633
|
+
retries: 9,
|
|
634
|
+
exponentialBackoff: true,
|
|
635
|
+
retryOnlyOnRetryableErrors: true,
|
|
636
|
+
maxBackoffInMs: 8000,
|
|
637
|
+
}),
|
|
638
|
+
).rejects.toThrow(APIException);
|
|
639
|
+
|
|
640
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("recovers when a rejected request starts succeeding", async () => {
|
|
644
|
+
mockedAxios
|
|
645
|
+
.mockRejectedValueOnce(createAxiosError({ status: 429 }))
|
|
646
|
+
.mockRejectedValueOnce(
|
|
647
|
+
createAxiosError({ code: "ECONNRESET", message: "socket hang up" }),
|
|
648
|
+
)
|
|
649
|
+
.mockResolvedValueOnce(createAxiosSuccess({ answer: "recovered" }));
|
|
650
|
+
|
|
651
|
+
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await post({
|
|
652
|
+
retries: 9,
|
|
653
|
+
exponentialBackoff: true,
|
|
654
|
+
retryOnlyOnRetryableErrors: true,
|
|
655
|
+
maxBackoffInMs: 8000,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
expect(mockedAxios).toHaveBeenCalledTimes(3);
|
|
659
|
+
expect((response as HTTPResponse<JSONObject>).data).toEqual({
|
|
660
|
+
answer: "recovered",
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("callers that have not opted in keep retrying everything", async () => {
|
|
665
|
+
alwaysFailWith(createAxiosError({ status: 400 }));
|
|
666
|
+
|
|
667
|
+
await post({ retries: 2, exponentialBackoff: true });
|
|
668
|
+
|
|
669
|
+
expect(mockedAxios).toHaveBeenCalledTimes(3);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe("totalTimeoutInMs", () => {
|
|
674
|
+
test("stops starting attempts once the budget is spent", async () => {
|
|
675
|
+
const startedAtInMs: number = nowInMs;
|
|
676
|
+
|
|
677
|
+
// Every attempt burns a full 120s per-attempt timeout.
|
|
678
|
+
alwaysFailWith(
|
|
679
|
+
createAxiosError({
|
|
680
|
+
code: "ECONNABORTED",
|
|
681
|
+
message: "timeout of 120000ms exceeded",
|
|
682
|
+
}),
|
|
683
|
+
120_000,
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
await expect(
|
|
687
|
+
post({
|
|
688
|
+
retries: 9,
|
|
689
|
+
exponentialBackoff: true,
|
|
690
|
+
maxBackoffInMs: 8000,
|
|
691
|
+
totalTimeoutInMs: 360_000,
|
|
692
|
+
timeout: 120_000,
|
|
693
|
+
}),
|
|
694
|
+
).rejects.toThrow(APIException);
|
|
695
|
+
|
|
696
|
+
/*
|
|
697
|
+
* Three attempts start inside the budget; a fourth would start past it.
|
|
698
|
+
* Without the budget this is ten attempts and twenty minutes.
|
|
699
|
+
*/
|
|
700
|
+
expect(mockedAxios).toHaveBeenCalledTimes(3);
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* The budget gates when an attempt STARTS, so the last one is still
|
|
704
|
+
* allowed to run its per-attempt timeout out past the line. It bounds the
|
|
705
|
+
* total at budget + one timeout, which is the guarantee that matters.
|
|
706
|
+
*/
|
|
707
|
+
expect(nowInMs - startedAtInMs).toBeLessThanOrEqual(360_000 + 120_000);
|
|
708
|
+
expect(nowInMs - startedAtInMs).toBeLessThan(10 * 120_000);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("leaves the full attempt budget available to failures that fail fast", async () => {
|
|
712
|
+
// A rate limit answers in milliseconds, so all ten attempts fit easily.
|
|
713
|
+
alwaysFailWith(createAxiosError({ status: 429 }), 10);
|
|
714
|
+
|
|
715
|
+
await post({
|
|
716
|
+
retries: 9,
|
|
717
|
+
exponentialBackoff: true,
|
|
718
|
+
maxBackoffInMs: 8000,
|
|
719
|
+
totalTimeoutInMs: 360_000,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("does not sleep out a budget it cannot use", async () => {
|
|
726
|
+
alwaysFailWith(createAxiosError({ status: 503 }), 1000);
|
|
727
|
+
|
|
728
|
+
await post({
|
|
729
|
+
retries: 9,
|
|
730
|
+
exponentialBackoff: true,
|
|
731
|
+
maxBackoffInMs: 8000,
|
|
732
|
+
totalTimeoutInMs: 2_000,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
/*
|
|
736
|
+
* Attempt 1 ends at 1000ms and the next wait would land at 2500ms, past
|
|
737
|
+
* the budget — so it gives up now rather than waiting first.
|
|
738
|
+
*/
|
|
739
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
740
|
+
expect(sleptForInMs).toEqual([]);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("is unbounded when the caller sets no budget", async () => {
|
|
744
|
+
alwaysFailWith(
|
|
745
|
+
createAxiosError({
|
|
746
|
+
code: "ECONNABORTED",
|
|
747
|
+
message: "timeout of 120000ms exceeded",
|
|
748
|
+
}),
|
|
749
|
+
120_000,
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
await expect(
|
|
753
|
+
post({ retries: 9, exponentialBackoff: true, maxBackoffInMs: 8000 }),
|
|
754
|
+
).rejects.toThrow(APIException);
|
|
755
|
+
|
|
756
|
+
expect(mockedAxios).toHaveBeenCalledTimes(10);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("a budget of zero still sends the first attempt", async () => {
|
|
760
|
+
alwaysFailWith(createAxiosError({ status: 503 }), 10);
|
|
761
|
+
|
|
762
|
+
await post({ retries: 9, exponentialBackoff: true, totalTimeoutInMs: 0 });
|
|
763
|
+
|
|
764
|
+
expect(mockedAxios).toHaveBeenCalledTimes(1);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("request configuration across attempts", () => {
|
|
769
|
+
test("every attempt carries the same method, url, body and per-attempt timeout", async () => {
|
|
770
|
+
mockedAxios
|
|
771
|
+
.mockRejectedValueOnce(createAxiosError({ status: 503 }))
|
|
772
|
+
.mockResolvedValueOnce(createAxiosSuccess());
|
|
773
|
+
|
|
774
|
+
await post({
|
|
775
|
+
retries: 9,
|
|
776
|
+
exponentialBackoff: true,
|
|
777
|
+
timeout: 120_000,
|
|
778
|
+
doNotFollowRedirects: true,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
expect(mockedAxios).toHaveBeenCalledTimes(2);
|
|
782
|
+
|
|
783
|
+
const first: AxiosRequestConfig = mockedAxios.mock
|
|
784
|
+
.calls[0]![0] as AxiosRequestConfig;
|
|
785
|
+
const second: AxiosRequestConfig = mockedAxios.mock
|
|
786
|
+
.calls[1]![0] as AxiosRequestConfig;
|
|
787
|
+
|
|
788
|
+
expect(first).toEqual(second);
|
|
789
|
+
expect(first.method).toBe(HTTPMethod.POST);
|
|
790
|
+
expect(first.url).toBe(TEST_URL.toString());
|
|
791
|
+
expect(first.data).toEqual({ hello: "world" });
|
|
792
|
+
expect(first.timeout).toBe(120_000);
|
|
793
|
+
expect(first.maxRedirects).toBe(0);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("keeps the SSRF guard's pinned agents on the retried attempt", async () => {
|
|
797
|
+
const httpAgent: Record<string, unknown> = { pinned: "http" };
|
|
798
|
+
const httpsAgent: Record<string, unknown> = { pinned: "https" };
|
|
799
|
+
|
|
800
|
+
mockedAxios
|
|
801
|
+
.mockRejectedValueOnce(createAxiosError({ status: 503 }))
|
|
802
|
+
.mockResolvedValueOnce(createAxiosSuccess());
|
|
803
|
+
|
|
804
|
+
await API.post<JSONObject>({
|
|
805
|
+
url: URL.fromString(TEST_URL.toString()),
|
|
806
|
+
data: { hello: "world" },
|
|
807
|
+
options: {
|
|
808
|
+
retries: 9,
|
|
809
|
+
exponentialBackoff: true,
|
|
810
|
+
doNotFollowRedirects: true,
|
|
811
|
+
httpAgent: httpAgent as never,
|
|
812
|
+
httpsAgent: httpsAgent as never,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
const retried: AxiosRequestConfig = mockedAxios.mock
|
|
817
|
+
.calls[1]![0] as AxiosRequestConfig;
|
|
818
|
+
|
|
819
|
+
expect(retried.httpAgent).toBe(httpAgent);
|
|
820
|
+
expect(retried.httpsAgent).toBe(httpsAgent);
|
|
821
|
+
expect(retried.maxRedirects).toBe(0);
|
|
822
|
+
});
|
|
823
|
+
});
|