pi-unsloth-webtools 0.4.0 → 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/package.json +1 -1
  3. package/web-fetch.ts +65 -24
package/README.md CHANGED
@@ -61,6 +61,9 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
61
61
  When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
62
62
  cannot stall a fetch), and a connection failure falls back to the next validated address for
63
63
  the same host before giving up.
64
+ - Transient DNS failures (EAI_AGAIN, resolver timeouts, connection refusals) are retried once
65
+ with a short backoff inside the same deadline, so a brief resolver hiccup does not fail a
66
+ fetch; the deadline abort cuts a retry short when no budget remains.
64
67
  - GitHub repo root pages are rewritten to the unauthenticated README API
65
68
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
66
69
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
package/web-fetch.ts CHANGED
@@ -28,6 +28,8 @@ const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
28
28
  const META_VALUE_MAX_CHARS = 300;
29
29
  const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
30
30
  const MAX_REQUESTS = 5;
31
+ const MAX_DNS_ATTEMPTS = 2;
32
+ const DNS_RETRY_BACKOFF_MS = 250;
31
33
  export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
32
34
  export { loadDefaultFetchMaxChars, loadDefaultFetchTimeoutMs, loadDefaultFetchSettings } from "./settings.ts";
33
35
  function remainingMs(deadline: number, now: () => number): number {
@@ -522,34 +524,73 @@ function decodeTis620(bytes: Buffer): string {
522
524
  }
523
525
 
524
526
 
527
+ const TRANSIENT_DNS_ERROR_CODES = new Set([
528
+ "EAI_AGAIN",
529
+ "ETIMEDOUT",
530
+ "ECONNREFUSED",
531
+ "ENETUNREACH",
532
+ "EHOSTUNREACH",
533
+ ]);
534
+
535
+ function isTransientDnsError(err: unknown): boolean {
536
+ if (!(err instanceof Error)) return false;
537
+ const code = (err as NodeJS.ErrnoException).code;
538
+ return typeof code === "string" && TRANSIENT_DNS_ERROR_CODES.has(code);
539
+ }
540
+
541
+ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
542
+ return new Promise((resolve) => {
543
+ if (signal?.aborted) return resolve();
544
+ const timer = setTimeout(resolve, ms);
545
+ signal?.addEventListener("abort", () => {
546
+ clearTimeout(timer);
547
+ resolve();
548
+ }, { once: true });
549
+ });
550
+ }
551
+
525
552
  async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
526
- let addresses: { address: string; family: number }[];
527
- try {
528
- const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
529
- all: true,
530
- verbatim: true,
531
- signal,
532
- };
533
- addresses = await dnsLookup(hostname, lookupOptions);
534
- } catch (err) {
535
- return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
536
- }
537
- if (!addresses.length) {
538
- return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
539
- }
540
- for (const entry of addresses) {
541
- if (!isPublicIp(entry.address)) {
542
- return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
553
+ let lastError: unknown = null;
554
+ for (let attempt = 0; attempt < MAX_DNS_ATTEMPTS; attempt++) {
555
+ if (signal?.aborted) break;
556
+ if (attempt > 0) {
557
+ await sleepAbortable(DNS_RETRY_BACKOFF_MS, signal);
558
+ if (signal?.aborted) break;
559
+ }
560
+ try {
561
+ const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
562
+ all: true,
563
+ verbatim: true,
564
+ signal,
565
+ };
566
+ const addresses = await dnsLookup(hostname, lookupOptions);
567
+ if (!addresses.length) {
568
+ return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
569
+ }
570
+ for (const entry of addresses) {
571
+ if (!isPublicIp(entry.address)) {
572
+ return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
573
+ }
574
+ }
575
+ addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
576
+ const first = addresses[0];
577
+ return {
578
+ ok: true,
579
+ reason: "",
580
+ ip: first.address,
581
+ family: first.family,
582
+ alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
583
+ };
584
+ } catch (err) {
585
+ lastError = err;
586
+ if (!isTransientDnsError(err)) break;
543
587
  }
544
588
  }
545
- addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
546
- const first = addresses[0];
547
589
  return {
548
- ok: true,
549
- reason: "",
550
- ip: first.address,
551
- family: first.family,
552
- alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
590
+ ok: false,
591
+ reason: lastError === null ? "Failed to resolve host: cancelled" : `Failed to resolve host: ${lastError}`,
592
+ ip: "",
593
+ family: 0,
553
594
  };
554
595
  }
555
596
  function budgetExceededResult(