{"id":51197648,"url":"https://github.com/erelsop/fetch-enh","last_synced_at":"2026-06-27T22:02:02.230Z","repository":{"id":356441557,"uuid":"1085365535","full_name":"erelsop/fetch-enh","owner":"erelsop","description":"FetchEnh(anced) is an NPM package and utility class designed to streamline fetch requests. Provides built-in support for handling common tasks like setting up base URLs, managing default headers, setting request timeouts, interceptors, retrying failed requests, and handling various response types. ","archived":false,"fork":false,"pushed_at":"2026-05-08T03:34:19.000Z","size":658,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-08T05:29:53.037Z","etag":null,"topics":["api","fetch-api","javascript","library","npm","typescript","utility"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/erelsop.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-10-29T00:08:35.000Z","updated_at":"2026-05-08T03:34:23.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/erelsop/fetch-enh","commit_stats":null,"previous_names":["erelsop/fetch-enh"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/erelsop/fetch-enh","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/erelsop%2Ffetch-enh","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/erelsop%2Ffetch-enh/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/erelsop%2Ffetch-enh/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/erelsop%2Ffetch-enh/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/erelsop","download_url":"https://codeload.github.com/erelsop/fetch-enh/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/erelsop%2Ffetch-enh/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34869004,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","fetch-api","javascript","library","npm","typescript","utility"],"created_at":"2026-06-27T22:02:01.539Z","updated_at":"2026-06-27T22:02:02.217Z","avatar_url":"https://github.com/erelsop.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# FetchEnh\n\nAn enhanced fetch utility for TypeScript and JavaScript with built-in retries, authentication strategies, interceptors, pagination, and structured errors.\n\n## Features\n\n- Automatic retries with backoff and jitter (with `Retry-After` support and a per-call retry override)\n- Authentication strategies: Bearer (with refresh), API Key (header or query), Basic (UTF‑8 safe), CSRF, OAuth2 client-credentials (Node) and OAuth2 + PKCE (browser)\n- Cross-origin credential stripping on redirect (`Authorization`, `Cookie`, `Cookie2`, `Proxy-Authorization`)\n- Request/response interceptors with priority ordering in a forward pipeline (lower `priority` numbers run first; `next()` is a no-op kept for API compatibility)\n- Timeouts and `AbortController` support, both global and per-request\n- Response parsing (`auto` content-type sniffing or explicit types) with 204/205/304 handled as `null`\n- Pagination — page/`pageSize` and cursor / `Link` header — exposed as both buffered (`get()`) and streaming (`getIter()`) variants\n- Structured errors with `toJSON()` (`FetchError`, `RetryError`, `TimeoutError`, `UnsupportedResponseTypeError`, `InterceptorAbortError`, `AuthAbortError`)\n- TypeScript-first API with `readonly` types and bounded `QueryValue` typing; works in browsers and Node.js (≥ 20)\n- Zero runtime dependencies\n- Dual CJS/ESM build with source maps, declaration maps, and `sideEffects: false` — tree-shakeable by modern bundlers (Vite, esbuild, Rollup, webpack 5+)\n\n## Installation\n\n```bash\nnpm install @erelsop/fetch-enh\n# or\nyarn add @erelsop/fetch-enh\n# or\npnpm add @erelsop/fetch-enh\n```\n\nThe package ships a dual build:\n\n| Condition | Output |\n|-----------|--------|\n| `require()` / CJS bundlers | `dist/index.js` |\n| `import` / ESM bundlers | `dist/esm/index.js` |\n| TypeScript types | `dist/index.d.ts` (also emitted into `dist/esm/`) |\n\n`dist/esm/` ships with its own `package.json` (`{\"type\":\"module\"}`) and `.js`-extended import paths so the ESM output works with native Node.js ESM (`import … from '@erelsop/fetch-enh'`) without requiring a bundler.\n\nBundlers that respect the `exports` map in `package.json` (Vite, esbuild, Rollup, webpack 5+) will automatically select the correct entry point. Source maps and declaration maps ship in both builds for debuggable production stack traces, and `\"sideEffects\": false` lets bundlers tree-shake unused exports.\n\n**Runtime requirement:** Node.js ≥ 20 (enforced via `engines`). The library uses global `fetch`, `AbortController`, `FormData`, `Blob`, `Headers`, and `URL`.\n\n## Quick Start\n\n```typescript\nimport FetchEnh from '@erelsop/fetch-enh';\n\n// All config fields are optional; new FetchEnh() with no args is valid.\nconst api = new FetchEnh({\n  baseURL: 'https://api.example.com',\n  defaultHeaders: { 'X-API-Version': '1.0' },\n  defaultTimeout: 5000,\n  defaultRetries: 3,\n});\n\nconst users = await api.get({ endpoint: '/users' });\nconst created = await api.post({ endpoint: '/users', body: { name: 'Jane' } });\n```\n\n## Configuration (constructor)\n\nAll fields are optional — `new FetchEnh()` (no arguments) is valid and uses sensible defaults.\n\n```ts\nnew FetchEnh({\n  baseURL?: string,\n  defaultHeaders?: Record\u003cstring,string\u003e,\n  defaultTimeout?: number,          // ms; 0 = no timeout (default)\n  defaultRetries?: number,          // default 3\n  queryStyle?: { array?: 'brackets'|'repeat'|'comma'; object?: 'brackets'|'dot' },\n  dedupe?: boolean,                 // default false; coalesces concurrent identical safe requests\n  dedupeKey?: (p:{method:string;url:string;body?:unknown}) =\u003e string,\n  onRetry?: (info:{attempt:number;delay:number;reason:'status'|'network';method:string;url:string;status?:number})=\u003evoid,\n  onComplete?: (info:{method:string;url:string;status?:number;ok:boolean;attempts:number;elapsedMs:number})=\u003evoid,\n});\n```\n\n`setConfig(config: FetchEnhConfig)` accepts the same options as the constructor and can be called at any time to update live settings. `defaultHeaders` is **merged** (not replaced) so partial updates don't clobber existing headers. Unrecognised keys produce a `console.warn`.\n\n## Methods (summary)\n\n- `get/post/put/patch/delete({ endpoint, headers?, query?, body?, responseType?, options?, bodyFactory? })`\n- `head({ endpoint, headers?, query? })` → `Promise\u003cResponse\u003e` (always returns the raw `Response`; HEAD has no body)\n- `getIter({ ... })` → `AsyncGenerator\u003cT[]\u003e` — streaming variant of `get()` that yields one page at a time (see [Pagination](#pagination))\n- `raw({ endpoint, method?, headers?, body?, query?, applyMiddleware?, signal? })` → `Promise\u003cResponse\u003e`\n  - By default, `raw()` skips all interceptors, auth, timeouts, and retries. Cross-origin redirects are still handled safely — sensitive headers (`Authorization`, `Cookie`, etc.) are stripped on cross-origin hops.\n  - Pass `applyMiddleware: true` to apply request interceptors, auth strategies (the **full** `AuthStrategy` contract — `onRequest` *and* `onAuthError`, so token-refresh strategies like `BearerTokenAuth` fire on 401/403), and response interceptors. Timeout and retry scaffolding are still skipped.\n  - Pass `signal` to provide an `AbortSignal` that cancels the underlying request.\n- `addRequestInterceptor` / `removeRequestInterceptor` / `clearRequestInterceptors`\n- `addResponseInterceptor` / `removeResponseInterceptor` / `clearResponseInterceptors`\n- `useAuthStrategy` / `removeAuthStrategy` / `clearAuthStrategies`\n- `setConfig`, `setRetryBehavior`, `setRetryClassifier`, `setBackoffStrategy`, `setRetryConfig`\n\nResponse types: `'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'response' | 'auto'` (default `'json'`; `'auto'` sniffs `Content-Type`). Responses with status `204 No Content` or `205 Reset Content` resolve to `null` for any `responseType` other than `'response'`.\n\n## Retries\n\nDefault: retries on 5xx, 429, and network errors (safe methods only — `GET`, `HEAD`, `OPTIONS`). `Retry-After` headers (delta-seconds and HTTP dates) are honoured by default and clamped to non-negative.\n\nCustomize globally:\n```ts\napi.setRetryBehavior(\n  { shouldRetry: ({ response, error }) =\u003e !!error || (!!response \u0026\u0026 (response.status \u003e= 500 || response.status === 429)) },\n  { computeDelay: ({ attempt }) =\u003e Math.min(1000 * 2**(attempt-1), 10000) },\n  { idempotentOnly: true, respectRetryAfter: true, maxElapsedMs: 30000, allowUnsafeRetries: false, idempotencyKeyFactory: () =\u003e crypto.randomUUID() }\n);\n\n// Pass null to revert classifier and/or backoff to built-in defaults in one call:\napi.setRetryBehavior(null, null);                              // revert both\napi.setRetryBehavior(null, null, { maxElapsedMs: 30_000 });   // revert both + update config\n\n// Or update piecewise:\napi.setRetryClassifier(null);          // revert to built-in (5xx + 429)\napi.setBackoffStrategy(null);          // revert to built-in exponential + jitter\napi.setRetryConfig({ maxElapsedMs: 60_000 }); // merge into existing config\n```\n\nCustomize per request:\n```ts\nawait api.post({\n  endpoint: '/jobs',\n  body: payload,\n  options: {\n    retries: 3,\n    retry: { idempotentOnly: false, allowUnsafeRetries: true, idempotencyKeyFactory: () =\u003e crypto.randomUUID() },\n  },\n});\n```\n\n### Replayable bodies\n\nPlain objects, strings, `URLSearchParams`, `Blob`, `ArrayBuffer`, and `FormData` are replayable across retries. `ReadableStream` bodies are **not** — if `retries \u003e 0` and a non-replayable body is detected, FetchEnh logs a warning and skips retries. To make stream-style bodies retryable, pass `bodyFactory` so each attempt gets a fresh body:\n\n```ts\nawait api.post({\n  endpoint: '/upload',\n  body: makeStream(),                // initial body\n  bodyFactory: () =\u003e makeStream(),   // fresh body for every retry\n  options: { retries: 3 },\n});\n```\n\n## Authentication\n\n```ts\nimport {\n  BearerTokenAuth,\n  ApiKeyAuth,\n  BasicAuth,\n  CsrfTokenAuth,\n  OAuth2ClientCredentialsAuth,\n  OAuth2PKCEAuth,\n  MemoryTokenStore,\n} from '@erelsop/fetch-enh';\n```\n\n- `BearerTokenAuth(store, refresh)` — sends `Authorization: Bearer \u003ctoken\u003e`; on 401 invokes `refresh()` (deduplicated across concurrent requests) and retries once.\n- `ApiKeyAuth({ headerName? | queryName?, getApiKey })` — exactly one of `headerName`/`queryName` is required; constructor throws if neither or both are provided.\n- `BasicAuth(username, password)` — UTF‑8 safe (uses `Buffer` in Node, `TextEncoder` + `btoa` in browsers).\n- `CsrfTokenAuth(headerName, getToken)` — pulls a CSRF token from any source you provide.\n- `OAuth2ClientCredentialsAuth({ tokenURL, clientId, clientSecret, scope?, tokenStore })` — **Node-only.** The constructor throws in browser contexts to prevent client-secret exposure. Surfaces token-endpoint errors with HTTP status and validates that the response contains a string `access_token`.\n- `OAuth2PKCEAuth({ tokenStore, refreshTokenStore?, getAccessToken, refreshWithRefreshToken? })` — browser-friendly OAuth 2.0 with PKCE; you supply the user-interactive functions, the strategy handles token storage, expiry tracking, and refresh.\n\n```ts\nconst api = new FetchEnh({ baseURL: '...' });\napi.useAuthStrategy(new BearerTokenAuth(new MemoryTokenStore('token'), async () =\u003e 'refreshed-token'));\n```\n\n### MemoryTokenStore TTL\n\n`MemoryTokenStore` supports optional time-to-live so you don't need to track expiry yourself:\n\n```ts\nconst store = new MemoryTokenStore();\n// token auto-expires after expires_in seconds\nstore.setTokenWithExpiry(json.access_token, json.expires_in * 1000);\n\n// Plain setToken() clears any pending TTL:\nstore.setToken('new-token');          // no expiry\n\n// Snapshot for debugging:\nconst { token, expiresAtMs } = store.getAll();\n```\n\n### Cross-origin credential stripping\n\nAll outbound `fetch()` calls go through a `safeFetch()` helper that performs manual redirect handling. On a cross-origin hop, sensitive request headers (`Authorization`, `Cookie`, `Cookie2`, `Proxy-Authorization`) are stripped before the next request is issued. Up to 20 hops are followed before a `Too many redirects` error is thrown. RFC 7231 method-switching rules are applied (303 → GET; 301/302 → GET for non-safe methods; 307/308 preserve method and body).\n\n\u003e **Browser opaque-redirect note:** In real browser environments (not jsdom), a response captured under `redirect: 'manual'` is *opaque* — its status is `0` and `Location` is not readable. In this case `safeFetch` returns the opaque response directly, which `_fetchAndParse` then treats as an HTTP error (`FetchError` with `status: 0`). If you need cross-origin redirect following in the browser, configure your server to emit `Access-Control-Allow-Origin` correctly so the browser can follow the redirect natively, or pre-resolve the redirect target server-side.\n\n## Interceptors\n\nInterceptors execute sequentially in a forward pipeline. `priority` controls ordering — **lower numbers run first**. Returning `false` halts the chain with a typed `InterceptorAbortError` (which is **not** retried).\n\n\u003e **`next()` note:** the `next` callback passed to each handler is a no-op kept for API compatibility. Handlers do not need to call it; the pipeline always advances to the next interceptor regardless.\n\n```ts\napi.addRequestInterceptor({ priority: 10, handler: async (req) =\u003e {\n  const h = new Headers(req.headers); h.set('X-Request-Time', Date.now().toString());\n  return new Request(req, { headers: h });\n}});\n\napi.addResponseInterceptor({ handler: async (res) =\u003e res });\n\n// Removal is by reference; clearing wipes everything:\napi.removeRequestInterceptor(myInterceptor);\napi.clearResponseInterceptors();\n```\n\n\u003e **Retry note:** request interceptors run **once per logical request call** (before the retry loop). Auth strategies run once per attempt so a token refresh during a retry window takes effect immediately.\n\n\u003e **Timeout note:** the timeout budget begins at the start of each fetch attempt, **before** auth strategies execute. Slow auth strategies consume timeout before the actual network call; keep them fast or increase `timeout` accordingly.\n\n## Response types\n\n```ts\nawait api.get({ endpoint: '/data', responseType: 'auto' });\nawait api.get({ endpoint: '/users', responseType: 'json' });\nawait api.get({ endpoint: '/image.png', responseType: 'blob' });\nawait api.get({ endpoint: '/status', responseType: 'response' });\n```\n\nThe TypeScript overloads ensure that `responseType: 'response'` resolves to `Promise\u003cResponse\u003e` while every other case resolves to `Promise\u003cT\u003e`.\n\n## Pagination\n\nPage-based:\n```ts\nawait api.get({ endpoint: '/users', page: 1, pageSize: 100, limit: 500, responseType: 'json' });\n// maxPages caps the number of page-fetches (default: 100 for both strategies)\nawait api.get({ endpoint: '/users', page: 1, pageSize: 20, maxPages: 10, responseType: 'json' });\n```\n\nCursor-based:\n```ts\nawait api.get({\n  endpoint: '/users',\n  responseType: 'json',\n  cursor: null,\n  cursorParamName: 'cursor',\n  getNextCursor: (resp) =\u003e resp.nextCursor,\n  extractor: (resp) =\u003e resp.items,\n  maxPages: 50,   // optional; defaults to 100\n});\n// Or useLinkHeader: true to parse Link headers — both relative and absolute URLs are handled:\n// e.g. Link: \u003c/items?cursor=tok\u003e; rel=\"next\"  OR  Link: \u003chttps://api.example.com/items?cursor=tok\u003e; rel=\"next\"\n```\n\nCursor pagination JSON parse failures **propagate as errors** rather than being silently swallowed.\n\n### Streaming pagination — `getIter()`\n\n`getIter()` is the async-generator variant of `get()`. It yields one page at a time so you can process huge result sets without buffering everything into memory:\n\n```ts\nfor await (const page of api.getIter\u003cUser\u003e({ endpoint: '/users', page: 1, pageSize: 50 })) {\n  await saveToDatabase(page);  // only one page is held in memory at a time\n}\n\n// Cursor-based:\nfor await (const page of api.getIter\u003cItem\u003e({\n  endpoint: '/items',\n  cursor: null,\n  useLinkHeader: true,\n})) {\n  process(page);\n}\n```\n\n`getIter()` accepts the same options as `get()`. For a non-paginated GET it yields a single one-element array containing the result.\n\n\u003e **Early-break cancellation:** when you `break` out of a `for await` loop (or the generator is closed via `return` / `throw`), any in-flight HTTP request for the current page is automatically aborted. This prevents wasting bandwidth and server-side work on results you no longer need — useful for \"find first match\" patterns over large cursor-paginated datasets.\n\n## Query parameters\n\n```ts\n// Defaults: arrays=brackets, objects=brackets\nawait api.get({ endpoint: '/search', query: { tags: ['js','ts'], filter: { status: 'active' } } });\n\n// Configure styles globally\nconst api2 = new FetchEnh({ baseURL: '...', queryStyle: { array: 'repeat', object: 'dot' } });\n```\n\nAccepted query value types are bounded by `QueryValue`: primitives (`string | number | boolean | Date`), arrays of primitives, one level of object nesting, or `null`/`undefined` (which are omitted).\n\n## Errors\n\nFetchEnh throws typed errors. All of them set `name`, expose a `code`, and provide `toJSON()`:\n\n| Class | `code` | Thrown when |\n|-------|--------|-------------|\n| `FetchError` | `EHTTP` | A non-retried HTTP error response is received (or retries are exhausted with a final non-OK response). |\n| `RetryError` | `ERETRY` | All network/error retries are exhausted; ES2022 `cause` carries the underlying error. |\n| `TimeoutError` | `ETIMEDOUT` | Per-request timeout fires, or `maxElapsedMs` retry budget is exceeded. |\n| `UnsupportedResponseTypeError` | `EUNSUPPORTED_RESPONSE` | An unknown `responseType` string is supplied. |\n| `InterceptorAbortError` | `EINTERCEPTOR_ABORT` | A request or response interceptor returned `false`. Not retried. |\n| `AuthAbortError` | `EAUTH_ABORT` | An auth strategy's `onRequest` returned `false`. Not retried. |\n\n```ts\nimport { FetchError, TimeoutError, RetryError } from '@erelsop/fetch-enh';\n\ntry {\n  await api.get({ endpoint: '/data' });\n} catch (e) {\n  if (e instanceof FetchError) {\n    console.log(e.toJSON());            // { name, code, status, method, url, attempts, elapsedMs, requestId, data }\n  } else if (e instanceof TimeoutError) {\n    console.log(e.code, e.elapsedMs);\n  } else if (e instanceof RetryError) {\n    console.log(e.attempts, e.cause);\n  }\n}\n```\n\n## Timeouts and Abort\n\nPer-request and global timeouts; you can also pass your own `AbortSignal`. The auth-retry path gets a **fresh** `AbortController` and timeout budget, so token-refresh round-trips can't immediately abort the retry.\n\n```ts\nconst c = new AbortController();\nawait api.get({ endpoint: '/slow', options: { timeout: 5000, signal: c.signal } });\n```\n\n## Hooks and deduping\n\n- `onRetry(info)`: called before each retry (`reason: 'status' | 'network'`).\n- `onComplete(info)`: called after every completion (success or error) with `attempts` and `elapsedMs`.\n- `dedupe`: coalesce concurrent identical **GET / HEAD / OPTIONS** requests into a single in-flight promise. Mutation methods (POST, DELETE, PATCH, PUT) are **not** deduplicated by default — each call produces its own side-effect. To opt mutation methods into deduplication, supply an explicit `dedupeKey` factory at construction time.\n\n\u003e **Note:** the dedup key is computed from the pre-interceptor `Request`. Interceptors that add unique headers or rewrite the URL are not reflected in the key, so semantically distinct requests may be coalesced. Disable dedup or supply a custom `dedupeKey` if your interceptors make requests unique.\n\n\u003e **Dedup cache bounds:** The dedup cache tracks only *in-flight* promises — each entry is removed as soon as the underlying request settles (success or failure). In practice this is self-limiting because requests resolve. In theory, a server that hangs indefinitely on every unique URL could cause the cache to grow without bound in very-long-lived processes. If this is a concern, avoid enabling `dedupe` against endpoints that may stall.\n\n## Environment\n\n- **Browser:** uses native `fetch`. Some headers (e.g. `User-Agent`) are restricted by the platform. Use `OAuth2PKCEAuth` — never `OAuth2ClientCredentialsAuth`, which throws in browsers to avoid leaking the client secret.\n- **Node.js:** works with global `fetch` (Node ≥ 20). Inject `fetch` via `OAuth2ClientCredentialsAuth`'s `fetchFn` parameter if you need a custom transport.\n\n## Contributing\n\nPRs welcome. To work on FetchEnh locally:\n\n```bash\ngit clone https://github.com/erelsop/fetch-enh.git\ncd fetch-enh\nnpm install\nnpm run build        # emits both CJS (dist/) and ESM (dist/esm/)\nnpm test             # 245 tests across 10 suites\nnpm run test:coverage\n```\n\nThe codebase targets Node ≥ 20. CI runs on Node 20 + 22 (Linux) with Node 22\nspot-checks on macOS and Windows. Both CJS and ESM artifacts are smoke-tested\nagainst a real `http.createServer` on every run.\n\n## License\n\nMIT\n\n## Links\n\n- Repository: https://github.com/erelsop/fetch-enh\n- Issues: https://github.com/erelsop/fetch-enh/issues\n- Examples: ./examples\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ferelsop%2Ffetch-enh","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ferelsop%2Ffetch-enh","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ferelsop%2Ffetch-enh/lists"}