@yroshcha/node-red-contrib-pod-gateway 2.0.5
Node-RED nodes and a WebSocket gateway for centrally rate-limited upstream API calls
@yroshcha/node-red-contrib-pod-gateway
Node-RED nodes for sending work through one central WebSocket gateway. Every POD keeps a persistent WebSocket connection to the gateway; only the gateway calls external APIs and consumes the shared Redis rate-limit budget.
Redis is required. The central Gateway does not start without a reachable Redis 6.2+ instance. Redis Streams, the global rate limiter, idempotency, delayed retries and pending-result replay all use Redis; MQTT is not a replacement for it.
The package is designed for one gateway instance and many POD connections:
POD 1 ──┐
├── WebSocket ──► Gateway ──► Redis Streams + GCRA ──► upstream API
POD 2 ──┘ │
└── result back to the originating POD
MQTT is not required. A POD never receives upstream credentials and never calls the provider API directly.
What it provides
- operation- or service-level global rate-limit buckets, shared by all PODs;
- a durable Redis Streams queue with
normalandbulkpriorities; - successful Stream-entry deletion and durable delayed retries without repeated
XADDcalls; - synchronous
Gateway Calland asynchronousGateway Out→Gateway In; - result routing back to the same WebSocket connector that submitted the work;
- a
Gateway Metricsoutput node for forwarding lifecycle and latency events; - idempotency and duplicate-result fan-out;
- retry-aware upstream HTTP errors and request deadlines;
- reconnect, pending-result replay and consumer recovery after a gateway restart;
- optional verbose lifecycle logs that never print payloads or API keys.
Release notes are tracked in CHANGELOG.md.
Quick start: the public demo
The fastest way to see the complete flow is to run one Node-RED instance that contains both the central gateway and a local POD connector.
Requirements: Node.js 18+, Node-RED, and Redis 6.2+ (mandatory). Redis
Streams and the XAUTOCLAIM command are used by the Gateway queue.
Install the palette in Node-RED through Manage palette → Install, or install it in the Node-RED runtime:
npm install @yroshcha/node-red-contrib-pod-gatewayStart Redis. For a local Docker setup:
docker compose up -d redisImport
examples/public-demo-flow.jsoninto Node-RED.Open Central Gateway + Redis and set
Redis URL. With Redis on the same host useredis://127.0.0.1:6379; with a Compose service namedredisuseredis://redis:6379.Open the API Ninjas config node and enter an API key in its credential field. The adapter calls
GET https://api.api-ninjas.com/v1/worldtime?timezone=Europe/Kyivwith the key inX-Api-Key.Deploy and click Call API Ninjas. The first branch returns the result to
Gateway Call. The result branch of the async example first reportsaccepted, thenGateway Inreceives the completed response.
The public flow contains no secret. Credentials are entered in Node-RED and are not stored in the flow export.
Docker URL rule
127.0.0.1 always means “this container”. If Node-RED and Redis are separate
containers, use the Redis service DNS name. If the gateway is in another
container, POD-side Gateway Config must use the gateway service DNS name,
for example ws://gateway:8080/ws.
The Node-RED model
Central gateway (one instance)
Create one Gateway Server Config node. It owns the WebSocket listener, Redis queue, global rate limiter and gateway authentication.
Run exactly one Gateway Server Config instance. Horizontal scaling of the gateway itself is not supported yet. The Redis Streams queue is multi-consumer-ready (a consumer group lets a second reader reclaim work after a crash), but each POD's live WebSocket connection is held only in the memory of the gateway process it happens to be connected to. A second gateway replica could dequeue a request that originated on a POD connected to the first replica, with no socket to deliver the result on -- that request would only reach the POD later, through the disconnected-POD pending-result/replay path, not immediately. Do not run more than one replica in Kubernetes or behind a load balancer; use a single replica with automatic restart (the
/healthzendpoint reportsqueueReadyandrateLimiterReadyso a liveness probe can restart the process if its Redis connections are down) for availability instead of horizontal scale-out.
Create one Gateway API Config per provider. It stores the provider base URL, common headers and API key in Node-RED credentials.
A header value on Gateway API Config or Gateway Adapter may use the
same {{...}} template syntax as _request.headers (see below), plus a
global root for Node-RED's global context: {{global.apiToken}},
{{global.tenant.id}}. This is the place for a credential that a separate
flow refreshes into global context, since these headers are operator config,
not POD input -- there is no protected-header restriction here the way there
is for _request.headers. {{payload.x}} and {{request.x}} also work,
resolved per request from the same call that resolves _request.headers. A
header with no {{...}} is unaffected and stays static, resolved once at
deploy, exactly as before. A missing or non-scalar {{global.x}} value fails
that call with INVALID_HEADER_TEMPLATE (non-retryable).
The main use case is Authorization itself. It is unconditionally on the
protected-header list (see below), so a POD's _request.headers can never
carry it -- an upstream bearer token has to come from the API Config or
Adapter. {{global.apiToken}} is what makes that workable when the token is
short-lived: keep a separate flow (e.g. an OAuth refresh timer) writing the
current token to global context, and set the Gateway API Config header
to:
Authorization: Bearer {{global.apiToken}}
It is re-read from global context on every request, so a refreshed token takes effect on the next call with no redeploy of the Gateway Adapter.
Header priority, highest first, for a header name set at more than one
level: _request.headers (POD) > Gateway Adapter headers > Gateway
API Config headers. This holds whether or not either config-level value
uses a {{...}} template -- a template is just resolved before the merge,
then the same last-value-wins order applies. The one exception is any
protected/credential header (Authorization, Cookie, Host,
Content-Length, Proxy-Authorization, X-Api-Key, X-Auth-Token,
X-Rapidapi-Key, Api-Key, or a custom apiKeyHeader): those names are
never settable from _request.headers, by design, so for them the priority
is only Adapter > API Config, with no POD override at any priority level.
Create one Gateway Adapter per operation. It reuses an API config and only needs an operation key, path and HTTP method:
Gateway API Config: https://api.api-ninjas.com
Gateway Adapter:
operation = worldtime/getKyivTime
path = /v1/worldtime?timezone=Europe/Kyiv
method = GET
The API path may contain values from the incoming payload or the standard
request object. Use {{payload.id}}, {{request.params.id}} (or the
equivalent {{msg.payload.id}} / {{msg._request.params.id}}) and the gateway
resolves it for every request:
/v1/users/{{payload.id}}?region={{payload.region}}
Template values are URL-encoded automatically. {{gateway.requestId}} can be
used when the upstream API accepts a request identifier. A missing or
non-scalar value fails with INVALID_URL_TEMPLATE; it is never replaced with
an empty string. The request payload itself is still passed to the adapter
unchanged.
Each Gateway Adapter also has a Keep-Alive checkbox (on by default).
Node's fetch already reuses persistent HTTP/1.1 connections to the same
upstream host between requests, so leaving it checked changes nothing.
Uncheck it only for a provider or load balancer that mishandles connection
reuse; the gateway then sends Connection: close on every request for that
operation, and a POD cannot turn it back on through _request.headers.
Standard request contract
Gateway Call and Gateway Out accept an optional msg._request object. The
API URL and credentials stay in the central API/Adapter configuration; a POD
can provide only per-request values:
msg.payload = { id: 'user-42', active: false }
msg._request = {
params: { id: 'user-42' }, // values for {{request.params.*}} in the path
query: { include: 'profile' }, // appended to the configured API path
headers: {
'X-Correlation-ID': msg._correlationId,
'X-Tenant-ID': '{{payload.tenantId}}',
'X-Request-ID': '{{gateway.requestId}}'
},
body: { active: false } // used by POST, PUT, PATCH and DELETE
}
return msg
params, query and headers must be objects with scalar values (query
arrays are repeated); API credentials and transport headers such as
Authorization, Cookie and Host remain controlled by Gateway API
Config. Header values may be resolved per request with the same template
syntax as the path, for example {{payload.tenantId}},
Bearer {{payload.accessToken}} or {{gateway.requestId}}. Header templates
are sent as-is (they are not URL-encoded); missing values and line breaks are
rejected before the request is queued. Protected credential/transport headers
cannot be overridden from a POD. GET and HEAD operations are query-only. POST, PUT, PATCH
and DELETE use _request.body; when it is absent, the original
msg.payload is sent as JSON. UPDATE is not an HTTP method—use PUT for a
replacement or PATCH for a partial update.
The same contract is validated in the POD node and again by the central
Gateway before a task enters Redis. Invalid fields fail immediately with
REQUEST_VALIDATION_FAILED; no upstream request is made.
Every completed call returns the API result in msg.payload and a canonical
input/output envelope:
msg._request = {
input: {
method: 'PUT',
params: { id: 'user-42' },
query: { include: 'profile' },
headers: { 'X-Correlation-ID': 'corr-1' },
body: { active: false },
payload: { id: 'user-42', active: false }
},
output: {
statusCode: 200,
body: { updated: true }
}
}
msg.gateway.httpStatus = 200
For an upstream error, output.statusCode contains the provider status and
output.body contains its response body when one was returned;
output.error contains the normalized gateway error. Gateway In forwards the
same envelope for asynchronous results.
The operation must use service/name. Rate-limit rules are resolved in this
order: exact operation (service/name), then the service prefix, then the
default rule. If only the service rule exists, all operations under that
service share one budget.
If one provider exposes many endpoints, keep the same service prefix and define the limit once. For example, these 20 operations all share one Redis bucket:
weather/getKyivCurrent
weather/getForecast
weather/getAirQuality
...any other weather/* operation
The matching Rate limits JSON is only:
{
"weather": { "rate": 10, "burst": 2 }
}
rate is the sustained requests-per-second budget and burst is the allowed
short burst. For independent endpoint budgets under one provider, use exact
operation keys:
{
"user/getInfo": { "rate": 20, "burst": 20 },
"user/update": { "rate": 1, "burst": 1 }
}
These rules create separate Redis buckets
pod-gateway:{user/getInfo}:g and pod-gateway:{user/update}:g.
You can also set the limit directly on each Gateway Adapter with its
Rate limit, per second/per minute and Burst fields. A value entered
there becomes an exact operation rule and overrides the matching JSON rule. The
per-minute choice is converted to requests per second before the Redis GCRA
check. For example, set 20 + per second + burst 20 on user/getInfo,
and 1 + per second + burst 1 on user/update. Burst cannot be smaller
than the entered Rate limit; the editor raises the minimum automatically. Leave
the Rate limit field empty to use the central JSON configuration instead.
POD-side flow
Create one Gateway Config per POD runtime and reuse it from all functional nodes. It contains only the WebSocket URL, POD ID, optional gateway token and reconnect settings.
| Node | Use it for | Outputs |
|---|---|---|
| Gateway Call | request/response work | output 1: result, output 2: error |
| Gateway Out | fire-and-forget or long async work | output 1: accepted, output 2: send error |
| Gateway In | result/event delivery for Gateway Out |
one message output |
The Gateway Call editor has an explicit Adapter / operation dropdown.
It is populated from the gateway's registered capabilities. Selecting an
adapter also shows its contract: HTTP method, path template and whether
params, query, headers and body are accepted. If the gateway is
temporarily unavailable, the current operation can still be entered manually.
Gateway Out and Gateway In expose the same operation picker. The
Gateway Adapter editor keeps operation entry manual because it is where a
new adapter is created; its separate HTTP method dropdown contains
GET, POST, PUT, PATCH, DELETE and HEAD.
The common shapes are:
[Inject] ─► [Gateway Call] ─► [result]
└────► [error]
[Inject] ─► [Gateway Out] ─► [accepted]
[Gateway In] ─► [completed async result]
Gateway Call waits over the WebSocket; it does not create a one-minute HTTP
request between the POD and gateway. Gateway Out resolves as soon as Redis
accepts the task. Its eventual result is delivered to every Gateway In
node using the same Gateway Config connection and matching operation filter.
Gateway metrics output
Add one Gateway Metrics node next to the central Gateway Server Config
and connect its output to any metrics palette. It emits metadata-only messages
for the Gateway lifecycle; no payloads, headers or credentials are forwarded.
Leave the event filter empty for all events, or enter a comma-separated list
such as request.completed, upstream.completed.
Available events are request.received, request.accepted,
request.processing, request.completed, request.rejected,
request.duplicate, upstream.started, upstream.completed and
rate_limit.decision.
For a completed request, msg.payload has this shape:
{
"event": "request.completed",
"service": "worldtime",
"operation": "getKyivTime",
"outcome": "success",
"httpStatus": 200,
"durationMs": 1347,
"timings": {
"queueMs": 210,
"rateLimitMs": 12,
"upstreamMs": 980,
"deliveryMs": 5,
"totalMs": 1347
}
}
Use timings.upstreamMs for the external API latency and
timings.totalMs for the end-to-end Gateway latency. The node's msg.topic
is pod-gateway/<event>, and the same object is also available as
msg.metric for palettes that use a dedicated metric field.
The upstream.completed event is an attempt-level event and exposes its
latency as durationMs instead of timings; select request.completed when
you need both upstream and end-to-end timings in one message.
Prometheus with @yroshcha/node-red-contrib-nodered-metrics
The Gateway package does not depend on a specific metrics implementation. If
you use @yroshcha/node-red-contrib-nodered-metrics, install that palette separately and import examples/metrics-flow.json. The example already contains its nodered-metric-config, nodered-metric and nodered-metrics-exporter nodes:
nodered_gateway_requests_total{service,operation,outcome}counts completed Gateway requests and creates a companion total-duration histogram;nodered_gateway_upstream_duration_seconds{service,operation,outcome}observes each external API attempt in seconds;- the exporter exposes the registry at
/nodeRedMetrics.
Prometheus can scrape the central Node-RED instance with:
scrape_configs:
- job_name: pod-gateway
metrics_path: /nodeRedMetrics
static_configs:
- targets: ["gateway:1880"]
Workers do not need the metrics palette when all external API calls run through the central Gateway.
Redis configuration
Redis is configured in Gateway Server Config, not in the POD-side Gateway Config node. Use one stable key prefix per gateway environment and one stable consumer name for the single gateway instance.
Typical settings:
Redis URL: redis://redis:6379
Redis key prefix: pod-gateway
Redis consumer: production-gateway
Max queue size: 10000
Rate limits JSON: {"upstream":{"rate":50,"burst":1}}
For TLS Redis use a rediss:// URL, for example:
rediss://user:[email protected]:6379
For a Redis TLS config like:
{
"host": "redis.example.com",
"port": 6379,
"tls-port": 6379,
"tls": { "rejectUnauthorized": false }
}
set these fields in Gateway Server Config:
Redis URL: rediss://redis.example.com:6379
Verify Redis TLS certificate: disabled
The tls-port value is expressed by the rediss:// scheme. If the Redis
deployment requires authentication, add the credentials to the URL, for
example rediss://:[email protected]:6379.
Keep Verify Redis TLS certificate enabled in production. Disable it only when certificate verification is intentionally handled by another trusted layer.
The queue uses these readable keys (with prefix pod-gateway):
pod-gateway:queue:normal
pod-gateway:queue:bulk
pod-gateway:queue:depth
pod-gateway:retry:normal # delayed retries, sorted by due timestamp
pod-gateway:retry:bulk
pod-gateway:retry:data:normal # payloads for delayed retry tokens
pod-gateway:retry:data:bulk
pod-gateway:{bucket}:g # service or exact operation bucket
# examples: pod-gateway:{upstream}:g, pod-gateway:{user/getInfo}:g
If Redis still shows <prefix>:{queue}:normal or <prefix>:{queue}:bulk, those
are legacy Stream names from an older build. The {queue} part was a Redis
Cluster hash tag, not a variable that should be expanded. Current builds use
<prefix>:queue:normal and <prefix>:queue:bulk; remove legacy keys only after
checking that they contain no pending work.
Redis Streams are acknowledged only after the adapter finishes and the result
has been delivered or persisted for replay. With Delete processed Stream
entries enabled (the default), the gateway then removes the acknowledged
entry with XDEL, so successful history does not accumulate in Redis. Disable
that option only for short debugging retention.
Rate-limited and retryable work is kept once in the retry:normal or
retry:bulk sorted set with its due timestamp. A small gateway retry loop
promotes it back to the Stream only when due; it does not append a new Stream
entry every second. The queue depth slot is transferred across the delayed
state, so Max queue size still bounds total live work. A pending Stream item
can be reclaimed after a crashed gateway. Historical consumer names may remain
in Redis after redeploys; a stable Redis consumer value prevents new names
from being created on every deploy.
Idempotency TTL (s) controls the expiration of Redis idempotency claims,
completed idempotency results and disconnected-POD pending results. Set it
longer than the period in which a duplicate request must be recognized; the
default is 3600 seconds. The same values are available in the standalone JSON
configuration as redis.resultTtlSeconds, redis.deleteOnAck and
redis.retryPollMs.
A pending Stream entry is reclaimed by XAUTOCLAIM after claimIdleMs
(default 120s). This is not an editor field: Gateway Server Config derives it
automatically as max(120000, Upstream timeout (ms) + 30000), so a slow
provider timeout never falls inside the reclaim window and a still-running
call cannot be dispatched a second time. If you set a longer per-request
upstreamTimeoutMs in msg._request than the configured default, or run the
standalone JS gateway with its own claimIdleMs, keep the same margin
yourself; the gateway logs a upstream timeout ... exceeds the Redis Stream claimIdleMs ... warning (once per operation) when it detects the mismatch at
runtime.
Authentication and security
- Use
wss://behind a TLS reverse proxy in production. - Set a gateway token in Gateway Server Config and the matching credential in each POD-side Gateway Config.
- Keep provider API keys only in Gateway API Config credentials or gateway process environment variables.
- Do not put secrets in an exported flow,
msg.payload, operation names or debug nodes. Verbose console loggingis intended for development. It logs request IDs, queue IDs and lifecycle state, never payloads, headers or credentials.
Error handling
Errors use a stable shape:
{
"code": "UPSTREAM_HTTP_429",
"message": "External API returned HTTP 429",
"retryable": true,
"retryAfterMs": 2000
}
Useful codes include:
| Code | Meaning |
|---|---|
GATEWAY_CONNECT_TIMEOUT |
POD could not complete the WebSocket handshake |
GATEWAY_ACCEPT_TIMEOUT |
Gateway did not acknowledge the task |
GATEWAY_RESULT_TIMEOUT |
Task was accepted but no result arrived before the POD deadline |
QUEUE_FULL |
Redis queue reached Max queue size |
RATE_LIMITER_UNAVAILABLE |
Redis limiter could not make a decision |
UPSTREAM_TIMEOUT |
The gateway aborted a slow provider request |
UPSTREAM_NETWORK_ERROR |
Provider DNS/TCP/TLS request failed |
UPSTREAM_HTTP_4xx/5xx |
Provider returned a non-2xx response |
REQUEST_VALIDATION_FAILED |
_request does not match the configured API method/path contract |
OPERATION_NOT_FOUND |
No adapter is registered for service/name |
For a failed Gateway Call, output 2 contains the original message plus
msg.error and msg.gateway.status = "failed". For an async operation,
Gateway Out reports acceptance on output 1; inspect errors from the matching
Gateway In path.
Importable examples
public-demo-flow.json— minimal real external API call; configure an API Ninjas key.external-api-flow.json— one-tab flow showing bothGateway CallandGateway Out→Gateway In.metrics-flow.json— central Gateway metrics output with a free Open-Meteo Kyiv weather call, Prometheus nodes from@yroshcha/node-red-contrib-nodered-metricsand debug outputs.full-demo-flow.json— one-tab local demo with a fake upstream HTTP endpoint; no external API account is required.retention-retry-flow.json— one-tab dev.29 demo for Redis XDEL retention, durable delayed retries, burst calls and Gateway latency metrics.custom-headers-flow.json— one-tab local GET/POST demo that echoes static and dynamic custom headers received by the upstream endpoint.local-flow.json— POD-only flow for the standaloneexamples/local-gateway.jsprocess.central-gateway-flow.json— central server, provider config and adapter only.basic-flow.json— smallest POD-side call.
All files are real Node-RED export arrays and can be imported directly.
If the metrics example reports UPSTREAM_HTTP_404, remove the old imported
adapter and import the current metrics-flow.json again. The adapter path must
be exactly /v1/forecast?... and the API base URL must be
https://api.open-meteo.com; existing Node-RED nodes are not changed when a
new package archive is installed.
Standalone JavaScript gateway
The package also exports GatewayServer, GatewayClient,
RedisGcraRateLimiter, RedisStreamQueue, createJsonHttpAdapter and the
requestContract helpers for an application that does not host the gateway in
Node-RED.
const {
GatewayServer,
RedisGcraRateLimiter,
RedisStreamQueue,
createJsonHttpAdapter,
requestContract,
loadGatewayConfig
} = require('@yroshcha/node-red-contrib-pod-gateway')
const config = loadGatewayConfig()
const limiter = await RedisGcraRateLimiter.connect({
url: config.redis.url,
keyPrefix: config.redis.keyPrefix,
limits: config.rateLimits
})
const queue = await RedisStreamQueue.connect({
url: config.redis.url,
prefix: config.redis.keyPrefix,
maxQueueSize: config.redis.maxQueueSize,
resultTtlSeconds: config.redis.resultTtlSeconds,
deleteOnAck: config.redis.deleteOnAck,
retryPollMs: config.redis.retryPollMs
})
const gateway = new GatewayServer({
port: config.server.port,
queue,
rateLimiter: limiter,
authenticate: async message => !config.auth.token || message.token === config.auth.token
})
gateway.registerOperation('worldtime/getKyivTime', createJsonHttpAdapter({
url: 'https://api.api-ninjas.com/v1/worldtime?timezone=Europe/Kyiv',
method: 'GET',
headers: { 'X-Api-Key': process.env.API_NINJAS_KEY }
}), {
label: 'World time',
requestSchema: requestContract.requestSchema('GET', '/v1/worldtime?timezone=Europe/Kyiv')
})
await gateway.start()
For a local standalone run:
cp gateway.config.example.json gateway.config.json
docker compose up -d redis
node examples/local-gateway.js
Then import examples/local-flow.json and use
ws://127.0.0.1:8080/ws.
Configuration file and environment overrides
The standalone command reads gateway.config.json; copy the safe template:
cp gateway.config.example.json gateway.config.json
npx pod-gateway
The file is ignored by npm/git examples because it may contain credentials. Deployment variables override file values:
REDIS_URL
REDIS_KEY_PREFIX
GATEWAY_MAX_QUEUE
PORT
GATEWAY_CONCURRENCY
GATEWAY_TOKEN
GATEWAY_RATE_LIMITS # JSON object
REDIS_RESULT_TTL_SECONDS
REDIS_DELETE_ON_ACK
REDIS_RETRY_POLL_MS
Real Gateway load test
npm run test:load starts a real GatewayServer, a real Redis Streams queue
and a real WebSocket client, then reports completion rate, throughput, queue
depth, delayed-retry depth and Redis used_memory before/after the run. The
adapter is local and deterministic, so no provider API key or external API is
needed.
The default smoke run sends 200 requests at 200 requests/second. To model the long-running 1 request/second budget with 50,000 incoming requests, run:
LOAD_REQUESTS=50000 LOAD_RATE=1 LOAD_LIMIT=1 npm run test:load
That scenario intentionally takes about 13 hours 53 minutes when every call
must pass through a 1 RPS upstream budget. Use a smaller LOAD_REQUESTS first
to validate connectivity. LOAD_LIMIT=0 disables the limiter for a fast queue
and retention/memory smoke test.
Troubleshooting
- Check
http://<gateway-host>:8080/healthz. A healthy gateway returns{"ok":true,...}, plusrateLimiterReady/queueReadywhen a rate limiter or Redis queue is configured -- either turningfalseis whyokwentfalseeven though the process is still running (e.g. a liveness probe restarting it makes sense once Redis is unreachable, not before). - If a POD is
disconnected, verify the WebSocket URL from that POD's network namespace.127.0.0.1is usually the wrong address across containers. - If a call is
acceptedwithout a queue ID, verify the installed package version and restart Node-RED completely; the queue path must logredis enqueue. - If RedisInsight shows a consumer group with zero entries, send a new task
and look for
redis enqueue,redis dispatchandredis ACKlogs. - Temporarily enable verbose logging in both server and POD config nodes. Disable it again after diagnosis.
Node-RED stores the value entered in each existing Gateway Server Config node. Installing a new palette archive does not rewrite that node. If an old Redis endpoint appears in the editor, open that config node and replace its value, or delete the old config node before importing a fresh example flow.
Development and release
npm install
npm test
npm pack --dry-run
npm pack
The test suite covers protocol validation, URL/header handling, upstream HTTP mapping, in-memory and Redis GCRA decisions, Redis Streams enqueue/dispatch/ ACK/idempotency/pending replay, gateway server routing, WebSocket client timeouts and duplicate aliases, helper functions, and every importable flow.
The real Redis end-to-end test is opt-in by availability: it uses
REDIS_URL when set and skips cleanly when Redis is not reachable.
Before publishing a stable release, update the version in package.json and
package-lock.json, run the commands above, inspect npm pack --dry-run, and
publish the generated package from a clean release environment:
npm publish --access public