Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,11 @@ searchCapability → describeCapability → invokeCapability

| Tool | What it does |
|---|---|
| `startAccessibilityScan` | Start a web accessibility scan of a page URL and retrieve a local CSV report path. Optional settings: `wcagVersion` (WCAG 2.0–2.2 at A/AA/AAA, or RGAA 4.1), `bestPractices`, `needsReview`, `advancedRules` and `experimentalRules`; the answer states the settings the scan ran with. Mobile apps are scanned through an App Automate run instead (`setupBrowserStackAppAutomateTests` with `accessibility: true`). |
| `startAccessibilityScan` | Start a web accessibility scan of a page URL and retrieve a local CSV report path. Optional settings: `wcagVersion` (WCAG 2.0–2.2 at A/AA/AAA, or RGAA 4.1), `bestPractices`, `needsReview`, `advancedRules` and `experimentalRules`; the answer states the settings the scan ran with. To scan the same page again (for example after a fix), pass the earlier scan's `scanId`: that starts a new run of that scan with its saved settings, which `fetchAccessibilityIssues` can compare with `compareWith: "previous"`. Mobile apps are scanned through an App Automate run instead (`setupBrowserStackAppAutomateTests` with `accessibility: true`). |
| `accessibilityExpert` | Ask the A11y Expert (WCAG 2.0/2.1/2.2, mobile/web usability, best practices). |
| `createAccessibilityAuthConfig` | Create an authentication configuration (form-based or basic) for accessibility scans behind a login. |
| `getAccessibilityAuthConfig` | Retrieve an existing accessibility authentication configuration by ID. |
| `fetchAccessibilityIssues` | Fetch accessibility issues, with pagination, from a Website Scanner run, an Automated Tests build (web) or App Accessibility build (mobile app, by the build id the SDK prints), a Workflow Analyzer report, an App Accessibility manual report (`appReportId`), or a report's dashboard link (a Website Scanner link is read by the run's build id, however old the scan; a Workflow Analyzer link by the report id it carries). Once the tests or scan have finished, waits while the report is still processing; a run still in progress is read as it is, with a note. For an app build with no scans, lists the likely causes. |
| `fetchAccessibilityIssues` | Fetch accessibility issues, with pagination, from a Website Scanner run, an Automated Tests build (web) or App Accessibility build (mobile app, by the build id the SDK prints), a Workflow Analyzer report, an App Accessibility manual report (`appReportId`), or a report's dashboard link (a Website Scanner link is read by the run's build id, however old the scan; a Workflow Analyzer link by the report id it carries). Once the tests or scan have finished, a report that is still processing is not waited on: the answer says to call again in a minute or two; a run still in progress is read as it is, with a note. For an app build with no scans, lists the likely causes. |
| `list_app_accessibility_builds` | List an App Accessibility project's builds newest first, with each build's id, status, app and issue counts - use this to find the latest run, a build by name, or one person's builds. |
| `list_app_accessibility_projects` | List the account's App Accessibility projects newest first, or find one by name - use this to get the project id that lists its builds. |
| `list_manual_app_accessibility_reports` | List manual App Accessibility reports (App Live sessions scanned with Workflow Analyzer) newest first, with each report's id, app, status and issue counts - use this to find a report QA shared or the latest manual scan of an app. |
Expand Down
59 changes: 57 additions & 2 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,47 @@ class ApiResponse<T = any> {
}
}

/**
* How long a connection gives each of a host's addresses before trying the next. Node's own
* default is 250 ms: on a slow route the IPv4 attempt is dropped that early and the IPv6 one
* fails where there is no IPv6 route, so the call fails with "connect ETIMEDOUT <IPv4>;
* connect ENETUNREACH <IPv6>" within a second. The hosted server saw exactly that.
*/
export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;

// Shared, so connections are reused as they are on Node's own global agent.
const directAgent = new https.Agent({
keepAlive: true,
autoSelectFamilyAttemptTimeout: CONNECT_ATTEMPT_TIMEOUT_MS,
});

/**
* A connect failure is retried only when it came within this long. A slower one means every
* address already timed out, so the host is down: failing now keeps loops (scan-list walks,
* polling) from stalling twice as long on it.
*/
export const CONNECT_RETRY_WINDOW_MS = 5000;

/** Failures to open the connection at all: nothing was sent, so a request can be repeated. */
const CONNECT_FAILURE =
/\bconnect (ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED)\b/;
const CONNECT_FAILURE_CODES = new Set([
"ETIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ECONNREFUSED",
]);

export function isConnectFailure(error: any): boolean {
if (!error || error.response) return false;
const causes = [error, error.cause, ...(error.errors ?? [])].filter(Boolean);
return causes.some(
(cause: any) =>
(cause.syscall === "connect" && CONNECT_FAILURE_CODES.has(cause.code)) ||
CONNECT_FAILURE.test(String(cause.message ?? "")),
);
}

// Utility to create HTTPS agent if needed (proxy/CA)
function getAxiosAgent(): AxiosRequestConfig["httpsAgent"] | undefined {
const proxyHost = config.browserstackLocalOptions.proxyHost;
Expand All @@ -88,10 +129,11 @@ function getAxiosAgent(): AxiosRequestConfig["httpsAgent"] | undefined {
// CA only
return new https.Agent({
ca: fs.readFileSync(caCertPath),
autoSelectFamilyAttemptTimeout: CONNECT_ATTEMPT_TIMEOUT_MS,
});
}
// Default agent (no proxy, no CA)
return undefined;
return directAgent;
}

class ApiClient {
Expand Down Expand Up @@ -148,7 +190,20 @@ class ApiClient {
try {
this.validateUrl(url, config);

const res = await fn(this.axiosAgent);
let res: AxiosResponse<T>;
const started = Date.now();
try {
res = await fn(this.axiosAgent);
} catch (error: any) {
// A connection that never opened sent nothing, so one more try is safe even for a
// POST; a second failure is reported as before. Only a fast failure is retried.
if (
!isConnectFailure(error) ||
Date.now() - started >= CONNECT_RETRY_WINDOW_MS
)
throw error;
res = await fn(this.axiosAgent);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: this retry and the new directAgent apply to every tool that uses apiClient, not just accessibility. I'm fine with that, since a connect failure sent nothing. But it's worth a line in the PR description so reviewers of other tools know about it.

Also, a host that really is down now takes up to (2 s × addresses) × 2 before failing, where it used to fail in under a second. The default case is fine, but tools that loop over many calls (scan-list walks, polling) could stall much longer when an upstream is down. Maybe skip the retry when the first attempt already spent its full per-address budget, or cap the total.

}
return new ApiResponse<T>(res);
} catch (error: any) {
if (error.response && !raise_error) {
Expand Down
Loading