@redis-iris/context-retriever
v0.1.1
Published
TypeScript SDK for Redis Context Retriever REST and MCP APIs
Readme
@redis-iris/context-retriever
TypeScript SDK for Redis Context Retriever: retrieve context for an agent over MCP, and administer the context surfaces that serve it over REST.
Summary
Redis Context Retriever: Administrative REST API for Redis Context Retriever.
Table of Contents
- @redis-iris/context-retriever
- Guides
- Before you start
- SDK Installation
- Requirements
- Control plane and data plane
- Quickstart: retrieve context
- Quickstart: provision a surface
- SDK Example Usage
- Authentication
- Available Resources and Operations
- Standalone functions
- Retries
- Error Handling
- Debug logging and redaction
- Transport security
- Custom HTTP Client
- Lifecycle hooks
- Debugging
- Development
Guides
| Guide | What it covers | | ----- | -------------- | | MCP.md | The data plane: connecting to a surface's MCP endpoint, persistent versus one-shot clients, agent keys, pooling. Start here if you are retrieving context. | | examples/ | Runnable scripts, including the canonical retrieval example. | | FUNCTIONS.md | Tree-shakable standalone functions. | | RUNTIMES.md | Supported JavaScript runtimes and recommended compiler options. | | CHANGELOG.md / RELEASES.md | The handwritten layer's changes, and the generated release history (empty until the first publish). |
Handwritten sections of this README, plus everything in MCP.md and
examples/, are maintained by hand and are not overwritten by SDK generation. See
Contributions.
Before you start
You need an on-prem Context Retriever deployment, its base URL, and an administrative
API key provisioned by that deployment. The SDK ships no default serverURL, and it
must be https: unless it points at a loopback host.
This package intentionally exposes only the on-prem API surface. Cloud browser-session authentication, EULA operations, and session-only admin-key creation are not included.
SDK Installation
The SDK can be installed with either npm, pnpm, bun or yarn package managers.
NPM
npm add @redis-iris/context-retrieverPNPM
pnpm add @redis-iris/context-retrieverBun
bun add @redis-iris/context-retrieverYarn
yarn add @redis-iris/context-retriever[!NOTE] This package is published with CommonJS and ES Modules (ESM) support.
[!NOTE] The first release has not been published yet, so the commands above will not resolve from npm until it is. Until then, build from this repository: see DEVELOPMENT.md.
Requirements
For supported JavaScript runtimes, please consult RUNTIMES.md.
Control plane and data plane
The SDK covers two distinct surfaces with two distinct credentials, and mixing them up is the most common source of a puzzling 401.
| | Control plane | Data plane |
| - | ------------- | ---------- |
| Protocol | Administrative REST | Streamable HTTP MCP |
| Entry point | createContextRetriever({ ... }) | ContextRetrieverMCPClient.connect({ ... }), or client.mcp.* |
| Credential | On-prem admin API key (X-API-Key) | Agent API key (X-API-Key), scoped to one surface, plus an optional end-user bearer token |
| Audience | The platform or data engineer who provisions surfaces | The application developer whose agent asks questions |
| Typical calls | Create and update surfaces, infer schemas, import data, mint and revoke agent keys, read activity logs | listTools, callTool |
| Runs where | A provisioning script, a Terraform-adjacent job, an internal admin console | Your request path, agent loop, or worker |
| Guide | This README | MCP.md |
An admin key must never be shipped to an agent, and an agent key cannot administer
anything. The bridge between the two planes is createAgentKey, which returns the
agent key and the surface's mcpUrl together.
Quickstart: retrieve context
This is what the product is for. Given a surface's MCP URL and an agent key, discover the tools the surface exposes and call one. No admin key is involved.
import { ContextRetrieverMCPClient } from "@redis-iris/context-retriever/mcp";
const mcp = await ContextRetrieverMCPClient.connect({
mcpURL: process.env["CONTEXT_RETRIEVER_MCP_URL"] ?? "",
agentApiKey: process.env["CONTEXT_RETRIEVER_AGENT_KEY"] ?? "",
timeoutMs: 30_000,
});
try {
// Tool names and argument shapes come from the surface's data model, so read
// them at runtime rather than hard-coding them.
const tools = await mcp.listTools();
console.log(tools.map(tool => tool.name));
const result = await mcp.callTool("search_customer_by_text", { query: "Ada" });
console.log(result.structuredContent);
} finally {
await mcp.close();
}Hold one client for as long as you need it. client.mcp.listTools(...) and
client.mcp.callTool(...) on the unified client do the same work in one line but open
and tear down an MCP session per call, so they suit scripts rather than hot paths.
Tool results are untrusted server data. Pass them to a model as data, never interpolated into a system prompt. MCP.md covers this, plus pooling, credential providers, and the full option set.
Quickstart: provision a surface
The control-plane half. This is what produces the mcpURL and agentApiKey above.
import { createContextRetriever } from "@redis-iris/context-retriever";
const client = createContextRetriever({
serverURL: process.env["CONTEXT_RETRIEVER_SERVER_URL"] ?? "",
auth: {
type: "apiKey",
apiKey: process.env["CONTEXT_RETRIEVER_API_KEY_AUTH"] ?? "",
},
});
const { result: surface } = await client.contextSurfaces.createContextSurface({
name: "customers",
dataSource: {
type: "redis",
connectionConfig: { addr: "redis.example.com:6379", tlsEnabled: true },
},
});
// A new surface starts in `provisioning`. Poll getContextSurface until its status is
// exactly `ContextSurfaceStatus.Active`, and fail on `failed` or `indices_failed`.
// Succeed only on `active`: treating "not pending" as ready reports a concurrently
// deleted surface, or any status the service adds later, as usable. Tools do not exist
// until the surface is active.
const { result: agentKey } = await client.agentKeys.createAgentKey(surface.id, {
name: "checkout-agent",
});
// `key` is returned exactly once. Store it now.
console.log(agentKey.mcpUrl, agentKey.key);See examples/provisionSurface.example.ts for
the complete flow, including schema inference, a correct polling loop, and inspecting
an import response for partial failures.
SDK Example Usage
Example
import { createContextRetriever } from "@redis-iris/context-retriever";
const contextRetriever = createContextRetriever({
serverURL: "https://api.example.com",
auth: {
type: "apiKey",
apiKey: process.env["CONTEXT_RETRIEVER_API_KEY"] ?? "",
},
});
async function run() {
const result = await contextRetriever.contextSurfaces
.listContextSurfaces();
console.log(result);
}
run();
The returned ContextRetrieverClient is a unified facade: its REST resources are
available directly (health, contextSurfaces, agentKeys, and others), the raw
generated REST client is available as rest, and one-shot MCP operations are available
as mcp.listTools(...) and mcp.callTool(...). Use the /mcp entry point when a
persistent MCP session is more efficient.
Authentication
Per-Client Security Schemes
Use createContextRetriever for supported deployment-aware authentication. It applies
credentials immediately before dispatch, so per-request headers cannot replace the
configured API key.
auth is required, and there is no zero-configuration path. The generated security
layer does read CONTEXT_RETRIEVER_API_KEY_AUTH from the environment, but only in
resolveGlobalSecurity, which serves the operations that take no security argument
(including the unauthenticated health probes). createContextRetriever never forwards
an apiKeyAuth option, and because auth is mandatory and the SDK's auth hook runs
last, the credential you configure always wins over an ambient one.
| Name | Type | Scheme | Configured via |
| ------------ | ------ | ------- | ------------------------------------------- |
| apiKeyAuth | apiKey | API key | auth: { type: "apiKey", apiKey } (required) |
For API-key authentication:
import { createContextRetriever } from "@redis-iris/context-retriever";
const contextRetriever = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
});
async function run() {
const result = await contextRetriever.contextSurfaces.listContextSurfaces();
console.log(result);
}
run();
The generated ContextRetriever class remains available as a low-level client.
Authentication matrix
ApiKeyAuth is an on-prem administrative API key sent as X-API-Key.
| Operations | Authentication |
| ---------- | -------------- |
| contextSurfaces.* (including inferSchema and importData) | Admin API key |
| agentKeys.* | Admin API key |
| activity.* | Admin API key |
| adminKeys.listAdminKeys and adminKeys.deleteAdminKey | Admin API key |
| adminKeys.validateAdminKey | The key under test is passed as the method argument |
| health.* (getHealth, getLiveness, getReadiness) | Unauthenticated |
| MCP listTools / callTool | Agent key plus an optional end-user bearer token |
Admin-key creation is deliberately absent because the upstream operation requires a cloud browser session. Provision the on-prem administrative key outside this SDK.
Validating an inbound key
validateAdminKey is the endpoint you reach for when your own service receives a
Context Retriever key and needs to know whether it is real. It takes the key under test
as a normal first argument, not through security:
const result = await client.adminKeys.validateAdminKey(inboundKey);
if (result.valid !== true) {
// Reject the caller.
}The key you pass is the key that gets validated. This is worth stating because it is
the one operation where the SDK's own credential must not be applied: X-API-Key is
the request's payload here, not its authentication. The auth hook detects a request to
/api/v1/keys/validate that already carries an X-API-Key header and leaves it alone,
so the answer is about inboundKey and not about the client's configured credential.
Without that carve-out the call would be an always-true oracle that reported the SDK's
own key as valid no matter what you passed in.
Credential providers
apiKey and csrfToken each accept a string or a function returning a string or a
promise of one. A provider is re-resolved on every request, including every retry
attempt, so a function is the supported way to rotate a credential without restarting
the process:
const client = createContextRetriever({
serverURL: process.env["CONTEXT_RETRIEVER_SERVER_URL"] ?? "",
auth: { type: "apiKey", apiKey: () => vault.read("context-retriever/admin-key") },
});A provider that returns an empty string, or anything that is not a string, is rejected
rather than sent: the REST client raises RequestValidationError and the MCP client
raises MCPError. Neither message echoes the provider or the value. An accidental
async () => ({ key }) therefore fails loudly instead of putting [object Object] in
an X-API-Key header.
Available Resources and Operations
Activity
- getSessionlessActivity - Get the sessionless tool-invocation bucket for a context surface
- listSessionlessActivityLogs - Get paginated sessionless tool invocation logs
- listActivitySessions - List tool sessions for a context surface
- getActivitySession - Get session summary
- listActivitySessionLogs - Get session invocation logs
AdminKeys
- listAdminKeys - List admin API keys for the authenticated account
- validateAdminKey - Validate an API key
- deleteAdminKey - Delete an admin API key
AgentKeys
- listAgentKeys - List agent keys for a context surface with pagination
- createAgentKey - Create an agent API key for a context surface
- deleteAgentKey - Delete an agent API key for a context surface
ContextSurfaces
- listContextSurfaces - List all context surfaces with pagination
- createContextSurface - Create a new context surface
- inferSchema - Infer entity schema from Redis sample data
- deleteContextSurface - Delete a context surface
- getContextSurface - Get a context surface by ID
- updateContextSurface - Update a context surface
- importData - Import data into a context surface
Health
- getHealth - Basic health check
- getLiveness - Liveness check
- getReadiness - Readiness check
Retrieval is not in the table above. It happens over MCP: see Quickstart: retrieve context and MCP.md.
Standalone functions
All the methods listed above are available as standalone functions. These functions are ideal for use in applications running in the browser, serverless runtimes or other environments where application bundle size is a primary concern. When using a bundler to build your application, all unused functionality will be either excluded from the final bundle or tree-shaken away.
To read more about standalone functions, check FUNCTIONS.md.
activityGetActivitySession- Get session summaryactivityGetSessionlessActivity- Get the sessionless tool-invocation bucket for a context surfaceactivityListActivitySessionLogs- Get session invocation logsactivityListActivitySessions- List tool sessions for a context surfaceactivityListSessionlessActivityLogs- Get paginated sessionless tool invocation logsadminKeysDeleteAdminKey- Delete an admin API keyadminKeysListAdminKeys- List admin API keys for the authenticated accountadminKeysValidateAdminKey- Validate an API keyagentKeysCreateAgentKey- Create an agent API key for a context surfaceagentKeysDeleteAgentKey- Delete an agent API key for a context surfaceagentKeysListAgentKeys- List agent keys for a context surface with paginationcontextSurfacesCreateContextSurface- Create a new context surfacecontextSurfacesDeleteContextSurface- Delete a context surfacecontextSurfacesGetContextSurface- Get a context surface by IDcontextSurfacesImportData- Import data into a context surfacecontextSurfacesInferSchema- Infer entity schema from Redis sample datacontextSurfacesListContextSurfaces- List all context surfaces with paginationcontextSurfacesUpdateContextSurface- Update a context surfacehealthGetHealth- Basic health checkhealthGetLiveness- Liveness checkhealthGetReadiness- Readiness check
[!WARNING] Standalone functions under
@redis-iris/context-retriever/funcs/*bypass theContextRetrieverErrorhierarchy. They return result objects carrying the raw generated error types instead. See the error-handling caveat.
Retries
Safe, idempotent read operations carry a default retry policy: exponential backoff
starting at 200ms, capped at 15s per interval, giving up after 15s of total elapsed
time, with an exponent of 1.5, retrying on 500, 502, 503, and 504 as well as
connection errors. A 429 is returned immediately so callers can schedule a retry from
RateLimitError.retryAfterMs. Non-idempotent operations are not retried by default,
and health probes are never retried so a readiness failure answers immediately. See
Retries and timeouts for the full policy and how to change
it.
To change the retry strategy for a single API call, provide a retries object to the
call. An explicit per-call retries always wins, including for non-idempotent
operations:
import { createContextRetriever } from "@redis-iris/context-retriever";
const contextRetriever = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
});
async function run() {
const result = await contextRetriever.contextSurfaces.listContextSurfaces(
undefined,
undefined,
{
retries: {
strategy: "backoff",
backoff: {
initialInterval: 200,
maxInterval: 15000,
exponent: 1.5,
maxElapsedTime: 15000,
},
retryConnectionErrors: true,
},
},
);
console.log(result);
}
run();
To override the retry strategy for every operation that supports retries, provide a
retryConfig at client construction:
import { createContextRetriever } from "@redis-iris/context-retriever";
const contextRetriever = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
retryConfig: {
strategy: "backoff",
backoff: {
initialInterval: 200,
maxInterval: 15000,
exponent: 1.5,
maxElapsedTime: 15000,
},
retryConnectionErrors: true,
},
});
async function run() {
const result = await contextRetriever.contextSurfaces.listContextSurfaces();
console.log(result);
}
run();
Retries and timeouts
Default retry policy. Safe reads get a bounded backoff, applied through the generation overlay:
| Setting | Value |
| ------- | ----- |
| strategy | backoff |
| initialInterval | 200ms |
| maxInterval | 15s |
| maxElapsedTime | 15s |
| exponent | 1.5 |
| Retried statuses | 500, 502, 503, 504 |
| retryConnectionErrors | true |
The covered reads are listContextSurfaces, getContextSurface, listAdminKeys,
validateAdminKey, listAgentKeys, and the five activity
operations. maxElapsedTime is deliberately short: the generator's own default is one
hour, which is unusable inside a user-facing request.
Rate limits are deliberately outside the automatic retry policy. A 429 is returned
after one attempt, and RateLimitError.retryAfterMs parses either delta-seconds or an
HTTP date from Retry-After. This lets the caller schedule work according to the
service's guidance without the SDK adding load during a throttling event.
Non-idempotent operations do not retry. createContextRetriever pins
retries: { strategy: "none" } onto deleteAdminKey,
createAgentKey, deleteAgentKey, createContextSurface, updateContextSurface,
deleteContextSurface, importData, and inferSchema. This holds even
when you set a client-wide retryConfig, because a 5xx or a timeout on a write often
means the write did land, and replaying it can duplicate a key or re-apply an import
batch.
Two ways to change that:
retryUnsafeOperations: trueat construction lets a client-wideretryConfigcover writes as well. Off by default.- A per-call
retriesoption always wins, for any operation. Use it when you know a specific write is safe to replay.
const client = createContextRetriever({
serverURL: process.env["CONTEXT_RETRIEVER_SERVER_URL"] ?? "",
auth: { type: "apiKey", apiKey: process.env["CONTEXT_RETRIEVER_API_KEY_AUTH"] ?? "" },
retryUnsafeOperations: true,
});Health probes never retry. getHealth, getLiveness, and getReadiness are
pinned to retries: { strategy: "none" } unconditionally, independent of
retryUnsafeOperations and independent of a client-wide retryConfig, so a
getReadiness returning 503 answers at once rather than blocking a load balancer for
the retry budget. A per-call retries is still honoured if you deliberately ask for
one.
Default timeout. createContextRetriever applies a 30 second deadline to every
request, with one exception: importData and inferSchema get 300000ms, because
schema inference samples Redis and calls an inference provider, and import is
synchronous and scales with batch size. Both would otherwise be killed by the 30 second
default, and because both are non-idempotent there is no safe retry to recover with.
Override either globally with timeoutMs, or per call:
const surface = await client.contextSurfaces.getContextSurface(id, {
timeoutMs: 5_000,
});An SDK deadline that fires raises TimeoutError with statusCode undefined. A
signal you pass raises ClientClosedRequestError. The raw generated
new ContextRetriever(...) client has no default deadline, so requests through it are
unbounded unless you set timeoutMs yourself.
[!WARNING] A
signalyou pass replaces the deadline; it does not combine with it. The generated transport only arms its ownAbortSignal.timeoutwhen nosignalis present, so a call that passessignalruns unbounded no matter whattimeoutMssays. To get both, compose them yourself:const surfaces = await client.contextSurfaces.listContextSurfaces(1, 10, { signal: AbortSignal.any([callerSignal, AbortSignal.timeout(30_000)]), });
Retries and the deadline interact: timeoutMs bounds each attempt, while
maxElapsedTime is checked between attempts before the next sleep. The conservative
wall-clock bound is therefore maxElapsedTime + maxInterval + timeoutMs; with the
default read policy that is 15s + 15s + 30s = 60s, not 30s. A caller-provided
AbortSignal is the way to impose a tighter end-to-end deadline, and it interrupts an
in-progress backoff sleep as well as the HTTP attempt. Compose it with
AbortSignal.timeout(...) as shown above when you need both caller cancellation and a
fixed call budget.
Error Handling
Every failure from a client built by createContextRetriever is a
ContextRetrieverError. The mapping is total: HTTP responses, client-side validation,
timeouts, cancellations, DNS and TLS failures, and MCP protocol failures all arrive as
subclasses, so a single instanceof ContextRetrieverError check is a reliable
backstop and nothing escapes as a bare TypeError or AbortError.
Properties
| Property | Type | Description |
| ------------------- | ----------------------- | ------------------------------------------------ |
| error.message | string | The service's message, with a remediation hint appended for known statuses |
| error.statusCode | number \| undefined | HTTP status. undefined means no HTTP response was received, so a write may or may not have been applied |
| error.details | unknown | Parsed JSON error body, validation data, or raw text. Scrubbed and non-enumerable: see below |
| error.headers | Headers \| undefined | Response headers, when a response was received |
| error.rawResponse | Response \| undefined | The untouched response |
| error.requestId | string \| undefined | Server correlation id, from x-request-id or x-correlation-id. Quote it in a support request |
| error.cause | unknown | A scrubbed stand-in for HTTP and validation failures, the original error for transport failures: see below |
details is deliberately hard to leak
A createAgentKey response body carries a plaintext key, and a rejected
createContextSurface body carries a Redis password. So details is defined as a
non-enumerable property holding an already-scrubbed copy of the payload. Two
consequences:
JSON.stringify(error)and a structured logger that walks own enumerable properties will not include it. Neither willconsole.error(error)in most runtimes. That is the point; do not "fix" it by copying the error into a plain object.- Reading it is a deliberate act:
error.detailsworks normally, and known secret-bearing fields are already replaced with<redacted>by the time you see them. Secrets drawn from the same payload are also masked insideerror.message, but on weaker terms than indetails: see Debug logging and redaction for the length floors and the shape heuristic that gate message masking. Treaterror.messageas sensitive.
error.headers and error.rawResponse are the unscrubbed originals. They are the
escape hatch for callers who need exactly what the service sent, so nothing is masked in
them: a response header such as x-api-key echoed by an ingress is readable verbatim.
error.rawResponse.bodyUsed is already true, because the generated transport read the
body to build the error, so only its headers and status are still readable.
error.cause behaves differently for payloads and for transport failures
Two modes, chosen by whether the underlying error carries a response body:
- Payload-bearing failures get a scrubbed stand-in
Error. That is every status-mapped class (BadRequestError,NotFoundError,RateLimitError,ServerError, and the rest),ResponseValidationError,RequestValidationErrorraised by a schema failure,MCPError, andAPIError. The stand-in carries the original'smessage,name, andstack, all three with secrets masked, and nothing else: nobody, nostatusCode, no nestedcause, and noinstanceofrelationship to the generated error class. UsestatusCode,details,headers,rawResponse, andrequestIdfor structured detail; the stand-in is for reading, not for branching. - Transport failures (
NetworkError,TimeoutErrorfrom the SDK's own deadline,ClientClosedRequestErrorfrom yourAbortSignal, and aRequestValidationErrorfrom a malformed request) get the original cause, untouched, including its nested chain.
The split exists because the two carry different things. The generated HTTP error
inlines the raw response body into its own message and keeps it on body, so
retaining it verbatim would put a plaintext agent key within reach of
console.error(error), which prints [cause] at the default inspect depth. A transport
error carries no payload at all, so scrubbing it buys nothing and costs the only
diagnostic an operator has: undici reports a refused connection as
{ code: "ECONNREFUSED" } on a nested cause, and a stand-in drops both the code and
the chain, making a DNS failure and a refused connection indistinguishable.
if (error instanceof NetworkError) {
// The original chain survives, so the transport's own code is reachable.
const cause = error.cause as { cause?: { code?: string } } | undefined;
console.error("transport code:", cause?.cause?.code); // e.g. "ECONNREFUSED"
}Error classes
| Class | Status | What to do |
| ----- | ------ | ---------- |
| RequestValidationError | none | Arguments failed client-side validation. Fix the call. Never retry. |
| BadRequestError | 400 | The service rejected the request as malformed. Fix the payload. Never retry. |
| AuthenticationError | 401 | Credential missing, expired, or wrong. Check the on-prem admin key used for control-plane calls. |
| AuthorizationError | 403 | Credential is valid but lacks the required scope. Never retry. |
| NotFoundError | 404 | No such resource. Check the identifier; it may also have been deleted. |
| ConflictError | 409 | Already exists, or in a conflicting state. Often safe to treat as success when creating idempotently. |
| UnprocessableEntityError | 422 | Well-formed but semantically invalid. Fix field values. Never retry. |
| ClientDependencyError | 424 | A dependency the service needs was unreachable, typically the surface's Redis data source or an inference provider. Verify the data source address and credentials. Often retryable. |
| RateLimitError | 429 | Back off. Use retryAfterMs. |
| ClientClosedRequestError | 499 or none | Cancelled. statusCode is 499 when the service reported it, undefined when your AbortSignal fired. Never retry automatically. |
| ServerError | 500 | The service failed unexpectedly. Retry idempotent calls; report requestId if it persists. |
| UpstreamError | 502 | An upstream dependency failed. Retry idempotent calls with backoff. |
| ServiceUnavailableError | 503 | Temporarily unavailable. Retry with backoff. |
| TimeoutError | 504 or none | Timed out. statusCode is 504 when the service reported it, undefined when the SDK's own deadline fired, in which case the request may still have been applied. |
| NetworkError | none | Transport failure: DNS, TLS, connection refused, socket reset. No response was received. Retry with backoff, but reconcile before replaying a write. |
| ResponseValidationError | any | The response did not match the SDK's schema, which usually means the service and the installed SDK version have diverged. Alert and upgrade. Do not retry. |
| MCPError | transport status, or none | An MCP transport, protocol, or result-validation failure, including using a closed client. statusCode carries the transport's HTTP status when there was one; 404 means the session is gone and the client must be replaced. See MCP.md. |
| APIError | any | Catch-all for a failure with no more specific class, including unmapped statuses. |
| ContextRetrieverError | any | Base class. Catch this as a backstop. |
Rate limiting
RateLimitError exposes both the raw header and a normalised delay. retryAfterMs is
parsed from delta-seconds, an HTTP-date, or the non-standard Retry-After-Ms header,
so callers do not have to handle three formats. Both are undefined when the service
sent no hint.
Parsing is strict on purpose. Only a plain integer or decimal ("30", "1.5") or an
HTTP-date is accepted; exponent notation and trailing junk such as "3abc" are treated
as malformed and yield undefined rather than a silently wrong number. The result is
clamped to 24 hours, so retryAfterMs is always safe to hand straight to setTimeout.
Because it is parsed from the header rather than from the retry config, it is also not
clamped by maxInterval, which makes it the right value to use when the service asks
for a backoff longer than the 15s retry budget.
if (error instanceof RateLimitError) {
const waitMs = error.retryAfterMs ?? 1_000;
console.log(`raw header: ${error.retryAfter ?? "none"}`);
await new Promise(resolve => setTimeout(resolve, waitMs));
}Example
import {
createContextRetriever,
ContextRetrieverError,
NetworkError,
NotFoundError,
RateLimitError,
} from "@redis-iris/context-retriever";
const contextRetriever = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
});
async function run() {
try {
const result = await contextRetriever.contextSurfaces.getContextSurface("abc");
console.log(result);
} catch (error) {
if (error instanceof NotFoundError) {
// The surface does not exist. Do not retry.
} else if (error instanceof RateLimitError) {
// Wait error.retryAfterMs before trying again.
} else if (error instanceof NetworkError) {
// No response was received; error.statusCode is undefined.
} else if (error instanceof ContextRetrieverError) {
// Backstop: the hierarchy is total, so this catches everything else.
console.error(error.name, error.statusCode, error.requestId, error.details);
} else {
throw error;
}
}
}
run();
examples/errorHandling.example.ts shows the full taxonomy, a backoff loop driven by
retryAfterMs, and how to reconcile a write whose outcome is unknown.
What does not get typed errors
Two escape hatches deliberately keep the generated contract, and neither is translated into this hierarchy:
client.rest, the raw generatedContextRetrieverinstance, and any client built withnew ContextRetriever(...). These reject with the generated error types from@redis-iris/context-retriever/models/errors.- The tree-shakable functions under
@redis-iris/context-retriever/funcs/*, which return result objects carrying generated errors rather than throwing.
Prefer the resource properties on the client returned by createContextRetriever.
Reach for rest or funcs/* only when you need the unmodified generated contract,
and handle generated error types explicitly when you do.
Debug logging and redaction
Debug logging prints every request header and every JSON body, which would otherwise write API keys, bearer tokens, Redis passwords, and other credential-bearing values into stdout, CI job logs, and any log aggregator.
createContextRetriever guards both routes into that output:
- A
debugLoggeryou pass is automatically wrapped inredactingLogger. CONTEXT_RETRIEVER_DEBUG=truenormally makes the generated core fall back to bareconsole.createContextRetrieverpre-empts that fallback by resolving a redacting logger itself, so the environment variable cannot bypass redaction.
What is matched
Matching is name-based. Names are compared with case and separators removed, so
api_key, apiKey, API-KEY, and ApiKey are one and the same name; that is what
covers both the wire's snake_case and the models' camelCase.
These header lines are replaced with <redacted>:
authorization, cookie, set-cookie, proxy-authorization, x-api-key,
x-csrf-token.
These body field names are replaced with <redacted>:
accesstoken, adminkey, agentkey, apikey, apikeyauth, authorization,
cookie, csrftoken, jsessionid, key, passwd, password, secret,
sessioncookie, token, useraccesstoken.
A credential in a URI's userinfo is masked too, because no name/value rule can see it:
redis://default:[email protected]:6379 logs as
redis://default:<redacted>@10.0.0.5:6379. This is the shape a data-source address
takes.
Nested payloads are walked to a depth of 64, and cycles are detected and rendered as
<circular>.
details masking is unconditional; message masking is not
This is the one asymmetry worth understanding, because it decides how you may treat
error.message.
ContextRetrieverError.details, and anything a redacting logger prints, is masked purely by field name. If the name matches the list above, the value is replaced, whatever it looks like and however short it is.Error.messagemasking is best-effort. The generated transport builds its messages by inlining the raw response body, so the same secret is already flattened into a string by the time the SDK sees it, with no field structure left to key off. Masking there works by collecting candidate secret values out of the structured payload and striking them out of the text, and a candidate has to clear two gates:| Gate | Rule | | ---- | ---- | | Length floor | 3 characters for an unambiguous credential name (
password,apikey,adminkey,agentkey,accesstoken,csrftoken,sessioncookie,jsessionid,authorization,cookie, and their separator variants). 24 characters forkey,secret, andtoken, which are ordinary business field names in customer records while this service's own key format runs past fifty characters. | | Shape | For aname: valueorname=valuepair found in a non-JSON body, the value must look like a credential: at least 12 characters, or containing a non-alphabetic character. |The floors are deliberately lopsided. A Redis password has no minimum length and must be masked even when it is three characters; a 6-character
tokenfield on a customer record is business data, and masking it would corrupt the diagnostic. The shape gate exists for the same reason in the other direction: without it,Invalid password: must be at least 12 characterslogs asInvalid password: <redacted> be at least 12 characters, destroying the message at the exact moment someone is reading it.Auth schemes are skipped rather than masked, so
authorization: Bearer <token>keeps the wordBearerand loses only the token. In the other direction, a line that merely looks like a header dump (authorization: ...) is redacted wholesale; that is the safe direction to err in, but it means a log line can lose more than the credential.
[!IMPORTANT] Treat
error.messageas sensitive. A name-based, floored redactor cannot mask a credential the service returns under a field name nobody anticipated, and it will not mask a short value under an ambiguous name.detailsis the property with the strong guarantee;messageis the one that is merely usually clean.
Exported functions
Three functions are exported.
| Export | Use it for |
| ------ | ---------- |
| redactingLogger(logger) | Wrap a logger so credentials never reach it. Applied automatically to any debugLogger you pass to createContextRetriever. |
| redactForOutput(value) | Redact one structured value, for logging a request or response payload through your own logger rather than the SDK's. Name-based and unconditional, like details. |
| redactSecretsInText(text, payload) | Mask secrets drawn from payload wherever they appear in text. This is the best-effort path described above, and the one the SDK uses on Error.message. Reach for it when you are assembling your own message out of a payload the SDK returned. |
import { redactForOutput, redactSecretsInText } from "@redis-iris/context-retriever";
myLogger.info("surface payload", redactForOutput(payload));
myLogger.error(redactSecretsInText(`import rejected: ${rawBody}`, payload));redactingLogger is exported too, so you can wrap your own logger before handing it to
anything else:
import { redactingLogger } from "@redis-iris/context-retriever";
const client = createContextRetriever({
serverURL: process.env["CONTEXT_RETRIEVER_SERVER_URL"] ?? "",
auth: { type: "apiKey", apiKey: process.env["CONTEXT_RETRIEVER_API_KEY_AUTH"] ?? "" },
// Wrapped automatically; wrapping it yourself is harmless and explicit.
debugLogger: redactingLogger(myLogger),
});[!WARNING] The raw generated client does not redact.
new ContextRetriever({ debugLogger })and thefuncs/*tree write credentials verbatim. Redaction is also name-based, so it cannot cover a secret returned under an unexpected field name. Treat debug output as sensitive regardless, and keep it out of shared CI logs.
CONTEXT_RETRIEVER_DEBUG accepted values
createContextRetriever recognises exactly two values as "on": "true" and "1".
| Value | Effect |
| ----- | ------ |
| true, 1 | Debug logging on, through a redacting logger. |
| false, 0, off, no, TRUE, True, yes, the empty string, or any other value | Debug logging off. |
| unset | Off. |
The matching is exact and case-sensitive on purpose. The generated core parses this
variable with z.coerce.boolean(), under which every non-empty string including
"false" is truthy, so CONTEXT_RETRIEVER_DEBUG=false would otherwise enable logging.
createContextRetriever pre-empts that by installing a no-op logger for any value it
does not recognise.
[!WARNING] That fix lives in
createContextRetriever, not in the generated core. A rawnew ContextRetriever({ ... })still coerces loosely, soCONTEXT_RETRIEVER_DEBUG=falseenables unredacted debug logging there.
An explicit debugLogger option always wins over the environment variable, in either
direction: passing one enables logging regardless of the variable, and the variable
cannot swap out the logger you passed.
Transport security
Credentials are sent as headers, and request bodies can contain Redis passwords, so
createContextRetriever refuses a plaintext serverURL:
// Throws RequestValidationError: serverURL must use https:
createContextRetriever({ serverURL: "http://context.example.com", auth });The check runs at two points. Construction is the early, friendly one; dispatch is the load-bearing one:
| Where | What it sees | Message says | How it surfaces |
| ----- | ------------ | ------------ | --------------- |
| Construction | options.serverURL | serverURL must use https: | Synchronous RequestValidationError from createContextRetriever |
| Immediately before every dispatch, inside the auth hook | The URL of the Request actually being sent | request URL must use https: | RequestValidationError as a promise rejection from the call |
The dispatch-time check is the one that matters, because a constructor-only check is
bypassable by anything that rewrites the target afterwards: an sdkInit hook returning a
different serverURL, a beforeCreateRequest hook rewriting input.url, a per-call
serverURL, or a mutated base URL. One check placed where the credential is attached
covers all of them, and nothing is dispatched when it fires.
https: URLs are always accepted, and localhost, 127.0.0.1, and ::1 are exempt
so local development needs no opt-in. For a non-loopback plaintext host, for example a
test container reached by service name, pass allowInsecureTransport: true and
understand that the admin credential then travels in the clear:
const client = createContextRetriever({
serverURL: "http://context-retriever.test:8080",
auth: { type: "apiKey", apiKey: "test-key" },
allowInsecureTransport: true,
});The same rule and the same option apply to mcpURL on
ContextRetrieverMCPClient.connect, except that a rejected MCP URL is reported as
MCPError rather than RequestValidationError.
A per-call serverURL override is checked too. It reaches the same credential, so
it has to clear the same bar. Because the methods it applies to are async, a rejected
per-call override arrives as a promise rejection rather than a synchronous throw, so an
ordinary catch sees it:
await client.contextSurfaces
.listContextSurfaces(1, 10, { serverURL: "http://elsewhere.example.com" })
.catch((error: unknown) => {
// RequestValidationError, with no `cause`: the transport check raises it
// directly rather than wrapping something. The message carries the same
// "must use https:" text as the constructor check.
});Redirects are not followed. Requests are dispatched with redirect: "manual".
undici strips Authorization and Cookie across origins but not custom headers, so a
followed 3xx would hand X-API-Key to whatever host the redirect named. Failing closed
is the safer default, but it has a visible consequence: a legitimate 3xx, for example
a deployment that has moved and answers 301, surfaces as an error rather than
transparently succeeding. If you hit that, point serverURL at the new location instead
of relying on the redirect.
Custom HTTP Client
The TypeScript SDK makes API calls using an HTTPClient that wraps the native
Fetch API. This
client is a thin wrapper around fetch and provides the ability to attach hooks
around the request lifecycle that can be used to modify the request or handle
errors and response.
The HTTPClient constructor takes an optional fetcher argument that can be
used to integrate a third-party HTTP client or when writing tests to mock out
the HTTP client and feed in fixtures.
The following example shows how to:
- route requests through a proxy server using undici's ProxyAgent
- use the
"beforeRequest"hook to add a custom header and a timeout to requests - use the
"requestError"hook to log errors
import { createContextRetriever } from "@redis-iris/context-retriever";
import { ProxyAgent } from "undici";
import { HTTPClient } from "@redis-iris/context-retriever/lib/http";
const dispatcher = new ProxyAgent("http://proxy.example.com:8080");
const httpClient = new HTTPClient({
// 'fetcher' takes a function that has the same signature as native 'fetch'.
fetcher: (input, init) =>
// 'dispatcher' is specific to undici and not part of the standard Fetch API.
fetch(input, { ...init, dispatcher } as RequestInit),
});
httpClient.addHook("beforeRequest", (request) => {
const nextRequest = new Request(request, {
signal: request.signal || AbortSignal.timeout(5000)
});
nextRequest.headers.set("x-custom-header", "custom value");
return nextRequest;
});
httpClient.addHook("requestError", (error, request) => {
console.group("Request Error");
console.log("Reason:", `${error}`);
console.log("Endpoint:", `${request.method} ${request.url}`);
console.groupEnd();
});
// The client is cloned and the SDK's auth hook is appended last, so a hook
// registered above cannot displace the configured credential.
const sdk = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
httpClient,
});Pass either fetch or httpClient, not both; supplying both throws a TypeError.
Lifecycle hooks
options.hooks takes an SDKHooks instance and gives you five points in the request
lifecycle. It is the instrumentation seam: tracing spans, metrics, request-id
propagation, and response inspection all belong here.
| Hook | Signature | Runs |
| ---- | --------- | ---- |
| sdkInit | (options) => options | Once, while the client is constructed. Can replace serverURL and the HTTP client. |
| beforeCreateRequest | (context, input) => input | Before the Request object exists, so it is the only place input.url can be rewritten. Synchronous. |
| beforeRequest | (context, request) => request | Once per attempt, including every retry. May throw to stop the request. |
| afterSuccess | (context, response) => response | On a successful status. |
| afterError | (context, response, error) => { response, error } | On an error status code. A thrown transport failure does not reach it; catch that at the call site. |
Every hook after sdkInit receives a HookContext carrying operationID, the
baseURL, the resolved retryConfig, and timeoutMs. operationID is a fixed string
drawn from the OpenAPI document (getContextSurface, importData, and so on), which
makes it the right thing to use as a metric label: the cardinality is bounded by the API
surface, unlike a URL path that carries surface ids.
import { createContextRetriever, SDKHooks } from "@redis-iris/context-retriever";
const hooks = new SDKHooks();
hooks.registerBeforeRequestHook({
beforeRequest: (context, request) => {
console.time(context.operationID);
return request;
},
});
hooks.registerAfterSuccessHook({
afterSuccess: (context, response) => {
console.timeEnd(context.operationID);
metrics.increment("context_retriever.request", {
// Bounded label: one value per API operation.
operation: context.operationID,
status: String(response.status),
});
return response;
},
});
const client = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
hooks,
});hooks versus httpClient.addHook
Both let you see a request before it is sent, and they are not interchangeable.
| | options.hooks (SDKHooks) | options.httpClient.addHook(...) |
| - | --------------------------- | --------------------------------- |
| Sees | Typed lifecycle events, with operationID and the resolved retry config | Only a bare Request, Response, or error |
| Knows which operation it is | Yes, via context.operationID | No |
| Can rewrite the target URL | Yes, in beforeCreateRequest | No: the Request already exists |
| Can replace the fetch implementation | Yes, in sdkInit | Yes, via the fetcher constructor option |
| Runs | Before the HTTP client's own hooks | After every SDKHooks hook |
Prefer hooks for anything that wants to know what is being called. Reach for
httpClient.addHook when you already hold a configured HTTPClient, or when a bare
Request is all you need.
What a hook cannot do
Hook order is a security property here, not an implementation detail. The full
beforeRequest order is: your SDKHooks hooks, then your HTTPClient hooks, then the
SDK's own auth hook, appended last. Three consequences, each covered by a test:
- A hook cannot displace the configured credential. The auth hook sets
X-API-Keyafter every hook of yours has run, so a hook that sets that header is overwritten rather than honoured. A caller-suppliedhttpClientis cloned before the auth hook is appended, so the instance you hold keeps its own hook list and does not start sending the SDK's credential on your own requests. - A hook cannot strip
redirect: "manual". The auth hook rebuilds the request with it, so a followed 3xx cannot handX-API-Keyto another origin no matter what a hook did. - A hook cannot reach a plaintext URL. The auth hook runs
the transport check against the URL actually being dispatched, so an
sdkInitorbeforeCreateRequesthook that rewrites the target tohttp:produces aRequestValidationErrorand dispatches nothing. See Transport security.
The one thing a hook can do is stop a request: throwing from beforeRequest aborts
it, and the error is translated into the ContextRetrieverError hierarchy like any
other failure.
Two limits, stated rather than implied. options.hooks must be an actual SDKHooks
instance; the generated core checks instanceof and silently ignores an object that
merely has the right shape. And an sdkInit hook that replaces options.httpClient
outright discards the SDK's auth hook with it, which removes the credential and the
dispatch-time transport check together, so requests then go out unauthenticated rather
than authenticated to the wrong place. Mutate the client you are given, or register your
hooks on it, rather than substituting a new one.
[!NOTE]
hooksandhttpClientcompose, butfetchandhttpClientdo not. Passing both throws aTypeError; pass afetcherto theHTTPClientconstructor instead.
Debugging
You can setup your SDK to emit debug logs for SDK requests and responses.
You can pass a logger that matches console's interface as an SDK option.
[!WARNING] Debug logging reveals request and response contents. A client built by
createContextRetrieverroutes debug output through a redacting logger, which masks credential headers and known secret-bearing fields, but debug output should still be treated as sensitive and used only during local development. The raw generated client does not redact at all. See Debug logging and redaction.
import { createContextRetriever } from "@redis-iris/context-retriever";
const sdk = createContextRetriever({
serverURL: "https://api.example.com",
auth: { type: "apiKey", apiKey: "<CONTEXT_RETRIEVER_API_KEY>" },
debugLogger: console,
});You can also enable a default debug logger by setting an environment variable CONTEXT_RETRIEVER_DEBUG to true.
Development
Maturity
This SDK is in beta, and there may be breaking changes between versions without a major version update. Therefore, we recommend pinning usage to a specific package version. This way, you can install the same version each time without breaking changes unless you are intentionally looking for the latest version.
Nothing has been published to npm yet; this package will publish as
@redis-iris/context-retriever 0.1.1. See CHANGELOG.md for what that
first release contains and the caveats worth knowing before you adopt it, and
RELEASES.md for the generated release history once it exists.
Contributions
This library is mostly generated programmatically from an OpenAPI document, but not
entirely. Changes to generated files are overwritten on the next generation. The paths
below are maintained by hand and are preserved across generation, as listed in
.genignore:
| Path | Contents |
| ---- | -------- |
| src/client.ts | createContextRetriever, the unified facade, auth, retry pinning, transport checks |
| src/errors.ts | The ContextRetrieverError hierarchy and error translation |
| src/lib/retries.ts | Generated, but carries a maintained edit: the backoff sleep accepts the caller's AbortSignal. Genignored so the edit is not overwritten. See DEVELOPMENT.md |
| src/redaction.ts | SDK-specific policy and non-object adapters around @pinojs/redact |
| src/mcp/** | The MCP client and its schemas |
| src/index.extras.ts | Re-exports that add the above to the package entry point |
| tests/**, tsconfig.test.json | The test suite |
| README.md, MCP.md, RUNTIMES.md | Hand-maintained documentation |
| .npmignore, jsr.json | Packaging |
| examples/.env.template, examples/package.json, examples/README.md | Example environment template, dependencies, and guide |
| The four hand-written examples/*.example.ts paths in .genignore | Runnable maintained examples |
When adding another hand-written example, add its explicit path to .genignore in the
same change. Generated examples are intentionally not ignored.
examples/*.ts are typechecked by npm run typecheck, which compiles examples,
src, and tests under the repository's own strict options (including
noPropertyAccessFromIndexSignature, so read environment variables as
process.env["NAME"] rather than process.env.NAME). An example that does not compile
fails CI, so snippets here and in MCP.md should be written to the same rules.
The npm tarball ships docs/**, MCP.md, USAGE.md, RUNTIMES.md, FUNCTIONS.md,
CHANGELOG.md, RELEASES.md, examples/README.md, examples/.env.template, and the
examples/*.example.ts scripts themselves, so the relative links in this README resolve
on npmjs.com. package.json also carries a repository field pointing at this
directory, which is what makes npm rewrite the remaining relative links against the
repository rather than dropping them. Verify the list with npm pack --dry-run after
changing .npmignore.
Within this README, keep the <!-- Start ... --> and <!-- End ... --> marker
comments intact and put new hand-written prose outside them, so a regeneration that
rewrites a marked region cannot take your content with it.
We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release.
Repository development workflows, including generation, publishing, and the generated versus handwritten split, are documented in DEVELOPMENT.md.
