Built-in HTTP client (app.fetch)
VextJS includes app.fetch, an enhanced client built on Node.js native fetch. It adds request ID propagation, timeouts, retries, structured logs, a create() factory and config-driven proxying without requiring a third-party HTTP library.
Run the local outbound request in Basic usage first, then consult configuration, proxy and retry behavior. Later standalone call snippets belong in a route, Service or plugin that already has an app. Replace example domains with actual service addresses.
Function overview
Production and development initialize app.fetch before user-plugin setup, Service constructors and route factories. Plugin setup, Service constructors and onReady callbacks registered on a real app can use app.fetch.create(). The route factory argument has the bound-method limitation described below, so handlers should use req.app.fetch. Later outbound calls also use logger wrappers installed through app.setLogger().
req.signal is cancelled on an interrupted request, a premature disconnect, or a route timeout. Receiving a complete POST body and completing a normal response do not cancel it; req.onClose() still performs cleanup on completion or disconnect. For ordinary app.fetch(), timeout covers obtaining response headers, not the subsequent response.text() or response.json() call. Proxy and streaming calls follow their own cancellation and timeout contracts.
Basic usage
Start with the TypeScript API-only project from Quick Start. Keep its package.json, tsconfig.json and scripts, merge this config and add the route. A separate route in the same process simulates an upstream without an external test API. For deployment, replace fetchDemoBaseURL with the internal service URL.
Run npm run dev, then request these URLs in another terminal (use curl.exe in PowerShell):
The first request returns 200 with data.user.id: "u-1", requestId: "fetch-demo-1" and tenant: "tenant-a"; the terminal shows a GET log with type: "outbound". The second returns 404, showing that the caller checks HTTP errors rather than accepting an error response as success.
Stop dev, run npm run build and npm start, repeat both requests, then stop with Ctrl+C. If the port changes, update fetchDemoBaseURL too. This tenant header demonstrates propagation only, not tenant authentication.
The general call accepts string | URL | Request and extended RequestInit, but timeout, retry and logging defaults differ from native fetch. It returns a standard Response: HTTP 4xx/5xx do not throw automatically. The caller must read the body and check response.ok.
The factory argument of defineRoutes((app) => ...) binds functions and currently does not retain attached methods such as fetch.get/create/proxy. Use req.app.fetch in a handler for the complete client; direct app.fetch(url, init) still works. Plugin setup and Service constructors receive a real app and are unaffected. In later shortcut examples, app means a real application instance; use req.app inside handlers.
Fetch Hooks
app.fetch and app.fetch.proxy will trigger outbound life cycle hooks, which are suitable for uniform header injection, recording third-party call time or reporting failures:
fetch:before and proxy:before can modify outbound headers; throwing stops the outbound request. An ordinary fetch:before runs outside the request loop, so its exception propagates directly without a later fetch:error. A proxy before-hook error enters proxy error handling and normally returns a local 502.
fetch:after/error and proxy:after/error use safe dispatch: listener errors are logged without replacing the main result. Before fires once per call; after fires when the final Response arrives, including the final attempt. HTTP 5xx still produces after, and a later body-read failure does not add fetch:error. Argument parsing and retry-delay evaluation also do not guarantee an error hook; see Hooks.
Shortcut method
In addition to calling app.fetch(url, init) directly, shortcuts to commonly used HTTP methods are also provided:
GET
POST
A non-null second argument to post, put or patch is JSON.stringify-encoded. The shortcut adds application/json only if no Content-Type is set. For FormData, binary or streams, use the general call and supply the body yourself:
PUT
PATCH
DELETE
List of method signatures
Configuration
Global configuration (config.fetch)
Configure global defaults in src/config/default.ts:
retry must be a non-negative integer counting extra attempts. timeout must be a finite positive number no greater than 2147483647 milliseconds. retryDelay must be finite and non-negative, also no greater than 2147483647 milliseconds. Function-form return values are checked before each native timer is created; invalid values fail fast.
The independent request metadata middleware reads configured names from inbound headers and writes them to requestContext.store.propagatedHeaders. app.fetch reads and injects them on outbound requests.
No need to manually pass these headers on every app.fetch call - the framework does the entire chain automatically.
Single request configuration (VextFetchInit)
Global configuration can be overridden per request via the init parameter:
All VextFetchInit fields
VextFetchInit inherits from the standard RequestInit and adds:
Single request init.timeout > options.timeout of create() > Global config.fetch.timeout
create() factory
For repeated calls to one downstream service, create() provides a preconfigured client with a baseURL and default headers:
This order route fragment requires the plugin above and two upstream services. userId comes from a validated body solely to show call organization; a real system should derive identity from authenticated context:
These upstream examples read unwrapped JSON such as { id }. If an upstream enables VextJS response wrapping, read its data. Supply serviceToken in your application config; see Plugins for app.extend() typing.
VextFetchClientOptions
A subclient can call create() again, but the current implementation reuses its parent factory. Do not assume it inherits that subclient's headers, timeout, retry or baseURL. Pass values that must be retained explicitly:
String paths join as baseURL + / + path; /users keeps /api/v1 in the baseURL. Even a full URL passed as a string is joined. To bypass baseURL, use root app.fetch or pass a URL or Request object to the callable subclient.
app.fetch.proxy request proxy
app.fetch.proxy is suitable for gateway, BFF or "forward the current request to an internal service" scenario. Its positioning is different from app.fetch.create(): create() returns a standard Response for the business code to process by itself; proxy receives the current req/res and writes the upstream response directly back to the client.
The upstream response will be transparently transmitted directly: 2xx / 3xx / 4xx / 5xx will not be packaged into { code, data, requestId }. Only proxy-local errors will be responded to with vext-style errors, such as missing path/url, target does not exist, Authorization passthrough is prohibited, upstream network error 502, or timeout 504.
Header merging and Authorization
The request header priority is:
Target and call-level forwardHeaders combine into a whitelist read from current req.headers; an empty call-level array does not clear the target whitelist. Proxy does not reuse ordinary fetch's ALS automatic propagation. To forward request ID, explicitly whitelist its header or inject req.requestId. Raw Authorization is not forwarded by default; target or call config must set allowAuthorizationForward: true and whitelist authorization.
Direct URL pattern
If you are only temporarily proxying to a full URL, you do not need to configure the target:
proxy retry rules
Proxy retry counts extra attempts, for retry + 1 total. Priority is options.retry > target.retry > config.fetch.retry > 0, likewise for retryDelay. Only GET / HEAD / OPTIONS / PUT / DELETE retry on upstream 5xx or network error; POST / PATCH do not. The body must be replayable; streamed bodies do not retry automatically. Timeouts do not retry. A timeout before response headers can return a local 504; after body streaming starts, the stream aborts and cannot be replaced with a complete JSON 504. Proxy timing covers the upstream response stream, and a client disconnect cancels upstream.
By default, proxy preserves the inbound method, merges inbound query with options.query taking priority (null/undefined removes a key), and reads the raw body for non-GET/HEAD calls without options.body. It uses manual redirects to preserve 3xx, strips hop-by-hop headers, Content-Length and Content-Encoding, and retains multiple Set-Cookie headers. This is not byte-for-byte copying of the HTTP message. See Fetch API for all options.
requestId automatically propagates
With request context enabled and an ID in its store, ordinary app.fetch can propagate that ID. The requestId middleware accepts a valid inbound header or creates an ID and writes it to requestContext (based on AsyncLocalStorage). The client then:
- Read the current
requestIdfromrequestContext.getStore() - Add the ID only if the outbound header is not already set. Its name follows
config.requestId.header, defaultx-request-id.
Disable requestId propagation
Some external APIs do not support custom headers and propagation can be disabled:
Forward selected request headers (propagateHeaders)
In addition to requestId, app.fetch can forward selected inbound request headers to downstream services. Typical examples are a tracing header (traceparent) and an application-specific tenant header (x-tenant-id).
Configuration method
Declare the header names that need to be transparently transmitted in config.fetch.propagateHeaders:
Working principle
The framework handles this chain for each inbound request:
Ordinary fetch adds captured store headers unless explicit init.headers or subclient defaults take priority. Capture requires request context; disabled context or a call outside its scope has no automatic propagation. init.propagateHeaders currently has no runtime filter or addition effect. The local example above demonstrates the path with x-tenant-id.
Forward a header for one request
If a header is not listed in the global propagateHeaders but one request needs it, set it explicitly in init.headers:
- requestId (vext built-in): automatically generated for log correlation and internal inter-service tracking
- traceId (APM system): generated by OpenTelemetry / Jaeger, etc., transparently transmitted through
propagateHeaders
Timeout control
app.fetch uses AbortController to implement timeout control. Throw an Error with explicit information after the timeout:
Pass { signal: req.signal } explicitly to propagate inbound cancellation; ordinary fetch does not read the current req.signal automatically. The caller signal combines with the internal timeout and retains its cancellation reason, stopping a request or retry wait. Ordinary timeout is per attempt and covers response headers only. Body reading still observes the caller signal. Total time also includes retries, retry waits and body consumption.
init.timeout, create({ timeout }), config.fetch.timeout, and proxy timeout follow the same boundary: a finite positive number no greater than 2147483647 milliseconds. retryDelay may be 0, but it must also be finite and no greater than 2147483647 milliseconds; function return values are validated before every retry.
Automatic retry
Retry requires an idempotent method (GET / HEAD / OPTIONS / PUT / DELETE), a replayable body and remaining attempts. POST / PATCH do not retry. A Request with an original body not replaced by replayable init.body, or a streaming body, does not retry. The upstream service still determines whether the business operation is truly idempotent.
List of idempotent methods
The following methods are considered idempotent and allow automatic retries:
Trigger conditions
Retry decision process
Behavior when the final retry fails
This is the most important detail - the final behavior of 5xx and network errors is different:
Retry log
Each retry will record a debug level log, including the current number of retries and the maximum number of retries:
The retry log is not recorded for the first request, and is only output when attempt >= 1. In the production environment, logger.level: 'info' will not output the retry log (the debug level is silenced).
Exponential backoff
retryDelay supports functional form to implement exponential backoff strategy:
The default retryDelay is fixed 1000ms (1 second).
Structured log
Each actual outbound attempt records a structured log; retry waits produce separate debug logs. Argument validation and a failing before-hook may happen before any attempt, so those paths do not guarantee this log. Fields include:
Log levels automatically adjust based on response status:
Example of log output:
duration is the time for a single attempt to obtain response headers, excluding retry wait and body consumption. Ordinary fetch follows redirects by default; only a 3xx actually returned is logged as warn. Logger thresholds still control output.
Replace fetch implementation
The current version does not expose the app.setFetch() public API, so it does not support directly replacing the framework's built-in app.fetch in the plug-in.
If your application already uses another HTTP SDK, mount it as a separate client with plugin app.extend() and follow that SDK's own installation and configuration instructions. Keep built-in app.fetch so framework behavior depending on it remains available.
If you bypass the built-in app.fetch, requestId propagation, timeouts, retries and structured logging will all need to be implemented yourself. In most scenarios, it is recommended to use the built-in app.fetch directly, or mount a dedicated client based on app.fetch.create().
Advanced example: organizing microservice clients
The two-file example above directly verifies outbound calls. This section shows business organization with a plugin and Service. To run it, supply user and inventory services, an order route calling this Service, and persistence. The three upstream contracts are GET /api/users/:id returning { id }, GET /api/stock/:id returning { available }, and POST /api/stock/:id/deduct accepting { quantity, orderId } with 2xx on success. Read data instead if an upstream wraps responses.
The caller should validate userId, productId and quantity and derive identity from authentication context. Inventory deduction must be atomic and idempotent by orderId; the business must implement compensation if order creation fails. A request ID correlates outbound calls when context exists but does not provide transactions, compensation or a full distributed Trace.
Next step
- Learn how requestId and request context generate and manage request IDs
- See plugins how to mount a custom client through
app.extend() - Explore global configuration items related to
fetchin Configuration - Learn how to mock
app.fetchfor unit testing in Testing