neptune-lambda-client 1.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test *)",
5
+ "Bash(TESTCONTAINERS_RYUK_DISABLED=true npm test)"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ node: [22, 24]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: ${{ matrix.node }}
22
+ cache: npm
23
+ - run: npm ci
24
+ - run: npm test
package/README.md CHANGED
@@ -2,24 +2,61 @@
2
2
 
3
3
  ## Overview
4
4
  A very simple Gremlin client to robustly query AWS Neptune from AWS Lambda. The client will automatically
5
- reestablish a connection to the database if the web socket connection closes and will also automatically
6
- retry (5 times) when it encounters `ConcurrentModificationException` and `ReadOnlyViolationException` errors.
5
+ reestablish a connection to the database if the web socket connection closes and will also automatically
6
+ retry (up to 5 times, with exponential backoff and jitter) when it encounters `ConcurrentModificationException`
7
+ and `ReadOnlyViolationException` errors.
8
+
9
+ ## Installation
10
+
11
+ `gremlin` is a peer dependency — install it alongside the client so your application controls the
12
+ exact version that talks to your Neptune cluster:
13
+
14
+ ```sh
15
+ npm install neptune-lambda-client gremlin
16
+ ```
7
17
 
8
18
  ## Usage
9
19
 
10
- This client is instantiated with a factory function and exposes a single function called `query`. `query` accepts
11
- a single argument, which is a function that use the Gremlin `g` object.
20
+ This client is instantiated with a factory function and exposes a `query` function that accepts a single
21
+ argument: a function that uses the Gremlin `g` object. It also exposes a `close` function for graceful shutdown.
12
22
 
13
23
  ```js
14
- const gremlinClient = require('neptune-lambda-client');
24
+ import { create } from 'neptune-lambda-client';
15
25
 
16
- const {query} = gremlinClient.create({
17
- host: 'neptune-db-url',
18
- port: '8182',
19
- useIam: true
20
- });
26
+ const { query } = create('neptune-db-url', '8182', { useIam: true });
21
27
 
22
28
  async function getNode(id) {
23
29
  return query(async g => g.V(id).next().then(x => x.value));
24
30
  }
25
31
  ```
32
+
33
+ ### Partition strategy
34
+
35
+ To scope every query through the client to a Gremlin
36
+ [`PartitionStrategy`](https://tinkerpop.apache.org/docs/current/reference/#partitionstrategy)
37
+ — useful for multi-tenant graphs and per-suite test isolation — pass a `partition`
38
+ option. Its fields are forwarded directly to `PartitionStrategy`:
39
+
40
+ ```js
41
+ const { query } = create('neptune-db-url', '8182', {
42
+ useIam: true,
43
+ partition: {
44
+ partitionKey: '_partition',
45
+ writePartition: 'tenant-a',
46
+ readPartitions: ['tenant-a', 'shared'],
47
+ },
48
+ });
49
+ ```
50
+
51
+ The strategy is reapplied automatically when the client reconnects after a dropped
52
+ WebSocket, so partition isolation holds across the full Lambda lifetime.
53
+
54
+ ## Known limitations
55
+
56
+ Per the [AWS Neptune Lambda guidance](https://docs.aws.amazon.com/neptune/latest/userguide/lambda-functions-examples.html#lambda-functions-examples-javascript),
57
+ if the underlying WebSocket is closed after the driver sends a request but before the response arrives,
58
+ the query resolves with `undefined` rather than throwing. Because this state cannot be turned into an
59
+ exception on the request/response path, we instead throw from the socket's `close` handler when the close
60
+ code is `1006` (abnormal closure) — this surfaces as an unhandled exception that fails the Lambda
61
+ invocation, so the client invoking the Lambda can retry. We recommend implementing retry logic on the
62
+ caller side as well as using the built-in retry in this library.
package/index.js CHANGED
@@ -1,10 +1,13 @@
1
- const aws4 = require('aws4');
2
- const gremlin = require('gremlin');
3
- const retry = require('async-retry');
1
+ import aws4 from 'aws4';
2
+ import gremlin from 'gremlin';
3
+ import { PartitionStrategy } from 'gremlin/lib/process/traversal-strategy.js';
4
+ import retry from 'async-retry';
4
5
 
5
6
  const traversal = gremlin.process.AnonymousTraversalSource.traversal;
6
7
  const {driver: {DriverRemoteConnection}} = gremlin;
7
8
 
9
+ const RETRYABLE_ERRORS = ['ConcurrentModificationException', 'ReadOnlyViolationException'];
10
+
8
11
  function createHeaders(host, port, path, options) {
9
12
  if (!host || !port) {
10
13
  throw new Error('Host and port are required');
@@ -33,75 +36,87 @@ function createHeaders(host, port, path, options) {
33
36
  }).headers;
34
37
  }
35
38
 
36
- module.exports = {
37
- create: function(host, port, {useIam = true} = {useIam: true}) {
38
- let conn = null;
39
- let g = null;
40
-
41
- const path = "/gremlin"
42
- const url = `wss://${host}:${port}${path}`
39
+ export function create(host, port, {useIam = true, protocol = 'wss', partition} = {}) {
40
+ let conn = null;
41
+ let g = null;
43
42
 
44
- const createRemoteConnection = () => {
43
+ const path = "/gremlin"
44
+ const url = `${protocol}://${host}:${port}${path}`
45
45
 
46
- const c = new DriverRemoteConnection(
47
- url,
48
- {
49
- mimeType: 'application/vnd.gremlin-v2.0+json',
50
- pingEnabled: false,
51
- headers: useIam ? createHeaders(host, port, path, {}) : {}
52
- });
46
+ const createRemoteConnection = () => {
53
47
 
54
- c._client._connection.on('log', message => {
55
- console.info(`connection message - ${message}`);
48
+ const c = new DriverRemoteConnection(
49
+ url,
50
+ {
51
+ // Lambda freezes between invocations; heartbeats fired just before freeze
52
+ // time out after thaw, causing noisy disconnects. Our reconnect-on-error path
53
+ // handles dead connections, so skip the liveness pings.
54
+ pingEnabled: false,
55
+ headers: useIam ? createHeaders(host, port, path, {}) : {}
56
56
  });
57
57
 
58
- c._client._connection.on('close', (code, message) => {
59
- console.info(`close - ${code} ${message}`);
60
- if (code == 1006){
61
- console.error('Connection closed prematurely');
62
- throw new Error('Connection closed prematurely');
63
- }
64
- });
58
+ // gremlin opens the WebSocket eagerly on construction; swallow a potential
59
+ // orphan rejection here. The real error resurfaces on the next submit().
60
+ c.open().catch(() => {});
61
+
62
+ c._client._connection.on('log', message => {
63
+ console.info(`connection message - ${message}`);
64
+ });
65
65
 
66
- return c;
67
- };
68
-
69
- const createGraphTraversalSource = conn => {
70
- return traversal().withRemote(conn);
71
- };
72
-
73
- return {
74
- query: async f => {
75
- if (conn == null){
76
- console.info('Initializing connection')
77
- conn = createRemoteConnection();
78
- g = createGraphTraversalSource(conn);
79
- }
80
-
81
- return retry(async (bail, count) => {
82
- return f(g).catch(err => {
83
- if(count > 0) console.log('Retry attempt no: ' + count);
84
- if (err.message.startsWith('WebSocket is not open')){
85
- console.warn('Reopening connection');
86
- conn.close();
87
- conn = createRemoteConnection();
88
- g = createGraphTraversalSource(conn);
89
- throw err;
90
- } else if (err.message.includes('ConcurrentModificationException')){
91
- console.warn('Retrying query because of ConcurrentModificationException');
92
- throw err;
93
- } else if (err.message.includes('ReadOnlyViolationException')){
94
- console.warn('Retrying query because of ReadOnlyViolationException');
95
- throw err;
96
- } else {
97
- console.warn('Unrecoverable error: ' + err);
98
- return bail(err);
99
- }
100
- })
101
- }, {
102
- factor: 1,
103
- retries: 5
104
- });
66
+ c._client._connection.on('close', (code, message) => {
67
+ console.info(`close - ${code} ${message}`);
68
+ if (code == 1006){
69
+ console.error('Connection closed prematurely');
70
+ throw new Error('Connection closed prematurely');
71
+ }
72
+ });
73
+
74
+ return c;
75
+ };
76
+
77
+ const createGraphTraversalSource = conn => {
78
+ const g = traversal().withRemote(conn);
79
+ return partition ? g.withStrategies(new PartitionStrategy(partition)) : g;
80
+ };
81
+
82
+ return {
83
+ query: async f => {
84
+ if (conn == null){
85
+ console.info('Initializing connection')
86
+ conn = createRemoteConnection();
87
+ g = createGraphTraversalSource(conn);
88
+ }
89
+
90
+ return retry(async (bail, count) => {
91
+ return f(g).catch(err => {
92
+ if(count > 0) console.log('Retry attempt no: ' + count);
93
+ if (err.message.startsWith('WebSocket is not open')){
94
+ console.warn('Reopening connection');
95
+ conn.close();
96
+ conn = createRemoteConnection();
97
+ g = createGraphTraversalSource(conn);
98
+ throw err;
99
+ }
100
+ if (RETRYABLE_ERRORS.some(name => err.message.includes(name))) {
101
+ console.warn('Retrying query: ' + err.message);
102
+ throw err;
103
+ }
104
+ console.warn('Unrecoverable error: ' + err);
105
+ return bail(err);
106
+ })
107
+ }, {
108
+ retries: 5,
109
+ factor: 2,
110
+ minTimeout: 1000,
111
+ maxTimeout: 10000,
112
+ randomize: true
113
+ });
114
+ },
115
+ close: async () => {
116
+ if (conn != null) {
117
+ await conn.close();
118
+ conn = null;
119
+ g = null;
105
120
  }
106
121
  }
107
122
  }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "neptune-lambda-client",
3
- "version": "1.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Gremlin client to robustly query AWS Neptune from AWS Lambda",
5
- "main": "index.js",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
6
9
  "scripts": {
7
- "test": "mocha"
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
8
12
  },
9
13
  "repository": {
10
14
  "type": "git",
@@ -25,7 +29,15 @@
25
29
  "homepage": "https://github.com/svozza/neptune-lambda-client#readme",
26
30
  "dependencies": {
27
31
  "async-retry": "1.3.3",
28
- "aws4": "1.11.0",
29
- "gremlin": "3.5.2"
32
+ "aws4": "1.11.0"
33
+ },
34
+ "peerDependencies": {
35
+ "gremlin": "^3.5.0"
36
+ },
37
+ "devDependencies": {
38
+ "gremlin": "3.5.2",
39
+ "testcontainers": "^12.0.0",
40
+ "vitest": "^2.1.9",
41
+ "ws": "^8.18.0"
30
42
  }
31
43
  }
@@ -0,0 +1,14 @@
1
+ import { GenericContainer, Wait } from 'testcontainers';
2
+
3
+ export async function startGremlinServer() {
4
+ const container = await new GenericContainer('tinkerpop/gremlin-server:3.7.3')
5
+ .withExposedPorts(8182)
6
+ .withWaitStrategy(Wait.forLogMessage(/Channel started at port 8182/))
7
+ .start();
8
+
9
+ return {
10
+ host: container.getHost(),
11
+ port: container.getMappedPort(8182),
12
+ stop: () => container.stop()
13
+ };
14
+ }
@@ -0,0 +1,32 @@
1
+ import { WebSocketServer } from 'ws';
2
+
3
+ export async function startFakeGremlin() {
4
+ const upgrades = [];
5
+ let resolveNextUpgrade;
6
+ let nextUpgrade = new Promise(resolve => { resolveNextUpgrade = resolve; });
7
+
8
+ const wss = new WebSocketServer({ port: 0, path: '/gremlin' });
9
+
10
+ wss.on('connection', (_socket, request) => {
11
+ const record = { headers: { ...request.headers }, url: request.url };
12
+ upgrades.push(record);
13
+ const resolve = resolveNextUpgrade;
14
+ nextUpgrade = new Promise(r => { resolveNextUpgrade = r; });
15
+ resolve(record);
16
+ });
17
+
18
+ await new Promise(resolve => wss.on('listening', resolve));
19
+ const { port } = wss.address();
20
+
21
+ return {
22
+ port,
23
+ upgrades,
24
+ waitForNextUpgrade: () => nextUpgrade,
25
+ close: () =>
26
+ new Promise(resolve => {
27
+ for (const client of wss.clients) client.close(1000, 'shutdown');
28
+ wss.close(() => resolve());
29
+ })
30
+ };
31
+ }
32
+
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest';
2
+ import { create } from '../index.js';
3
+ import { startFakeGremlin } from './helpers/gremlin-server.js';
4
+
5
+ let server;
6
+
7
+ beforeEach(async () => {
8
+ server = await startFakeGremlin();
9
+ });
10
+
11
+ afterEach(async () => {
12
+ await server.close();
13
+ });
14
+
15
+ function createClient(opts = {}) {
16
+ const client = create('localhost', server.port, { useIam: false, protocol: 'ws', ...opts });
17
+ onTestFinished(() => client.close());
18
+ return client;
19
+ }
20
+
21
+ describe('create() / query()', () => {
22
+ it('resolves with the user function result on the happy path', async () => {
23
+ const client = createClient();
24
+ const result = await client.query(async () => 42);
25
+ expect(result).toBe(42);
26
+ });
27
+
28
+ describe('with faked retry timers', () => {
29
+ beforeEach(() => {
30
+ vi.useFakeTimers({ toFake: ['setTimeout'] });
31
+ });
32
+ afterEach(() => {
33
+ vi.useRealTimers();
34
+ });
35
+
36
+ it('retries on ConcurrentModificationException and eventually succeeds', async () => {
37
+ const client = createClient();
38
+ let calls = 0;
39
+ const p = client.query(async () => {
40
+ calls++;
41
+ if (calls < 3) throw new Error('ConcurrentModificationException');
42
+ return 'ok';
43
+ });
44
+ await vi.runAllTimersAsync();
45
+ expect(await p).toBe('ok');
46
+ expect(calls).toBe(3);
47
+ });
48
+
49
+ it('retries on ReadOnlyViolationException and eventually succeeds', async () => {
50
+ const client = createClient();
51
+ let calls = 0;
52
+ const p = client.query(async () => {
53
+ calls++;
54
+ if (calls < 2) throw new Error('ReadOnlyViolationException: stale primary');
55
+ return 'ok';
56
+ });
57
+ await vi.runAllTimersAsync();
58
+ expect(await p).toBe('ok');
59
+ expect(calls).toBe(2);
60
+ });
61
+
62
+ it('reconnects when the error is "WebSocket is not open"', async () => {
63
+ const client = createClient();
64
+ let calls = 0;
65
+ const p = client.query(async () => {
66
+ calls++;
67
+ if (calls === 1) throw new Error('WebSocket is not open: readyState 3 (CLOSED)');
68
+ return 'reconnected';
69
+ });
70
+ await vi.runAllTimersAsync();
71
+ expect(await p).toBe('reconnected');
72
+ expect(calls).toBe(2);
73
+
74
+ // The new WebSocket handshake runs on real I/O; wait for it before asserting.
75
+ vi.useRealTimers();
76
+ await vi.waitFor(() => expect(server.upgrades.length).toBeGreaterThanOrEqual(2), { timeout: 2000 });
77
+ });
78
+
79
+ it('gives up after the retry cap', async () => {
80
+ const client = createClient();
81
+ let calls = 0;
82
+ const p = client.query(async () => {
83
+ calls++;
84
+ throw new Error('ConcurrentModificationException always');
85
+ });
86
+ const assertion = expect(p).rejects.toThrow('ConcurrentModificationException');
87
+ await vi.runAllTimersAsync();
88
+ await assertion;
89
+ expect(calls).toBe(6);
90
+ });
91
+ });
92
+
93
+ it('does not retry on unrecoverable errors', async () => {
94
+ const client = createClient();
95
+ let calls = 0;
96
+ await expect(
97
+ client.query(async () => {
98
+ calls++;
99
+ throw new Error('some random failure');
100
+ })
101
+ ).rejects.toThrow('some random failure');
102
+ expect(calls).toBe(1);
103
+ });
104
+ });
105
+
106
+ describe('IAM signing', () => {
107
+ afterEach(() => {
108
+ vi.unstubAllEnvs();
109
+ });
110
+
111
+ it('sends a SigV4-signed Authorization header when useIam is true', async () => {
112
+ vi.stubEnv('AWS_ACCESS_KEY_ID', 'AKIATEST');
113
+ vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'secret');
114
+ vi.stubEnv('AWS_REGION', 'eu-west-1');
115
+ vi.stubEnv('AWS_SESSION_TOKEN', undefined);
116
+
117
+ const client = createClient({ useIam: true });
118
+ const upgradePromise = server.waitForNextUpgrade();
119
+ await client.query(async () => 'done');
120
+ const upgrade = await upgradePromise;
121
+
122
+ expect(upgrade.headers.authorization).toMatch(/^AWS4-HMAC-SHA256/);
123
+ expect(upgrade.headers['x-amz-date']).toBeDefined();
124
+ });
125
+
126
+ it('sends no Authorization header when useIam is false', async () => {
127
+ const client = createClient();
128
+ const upgradePromise = server.waitForNextUpgrade();
129
+ await client.query(async () => 'done');
130
+ const upgrade = await upgradePromise;
131
+
132
+ expect(upgrade.headers.authorization).toBeUndefined();
133
+ expect(upgrade.headers['x-amz-date']).toBeUndefined();
134
+ });
135
+
136
+ it('rejects when useIam is true but AWS env vars are missing', async () => {
137
+ vi.stubEnv('AWS_ACCESS_KEY_ID', undefined);
138
+ vi.stubEnv('AWS_SECRET_ACCESS_KEY', undefined);
139
+ vi.stubEnv('AWS_REGION', undefined);
140
+ vi.stubEnv('AWS_SESSION_TOKEN', undefined);
141
+
142
+ const client = createClient({ useIam: true });
143
+ await expect(client.query(async () => 'done')).rejects.toThrow(
144
+ 'AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION are required'
145
+ );
146
+ });
147
+ });
@@ -0,0 +1,155 @@
1
+ import { beforeAll, afterAll, describe, it, expect, onTestFinished } from 'vitest';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { create } from '../index.js';
4
+ import { startGremlinServer } from './helpers/gremlin-container.js';
5
+
6
+ let server;
7
+
8
+ beforeAll(async () => {
9
+ server = await startGremlinServer();
10
+ }, 120_000);
11
+
12
+ afterAll(async () => {
13
+ await server?.stop();
14
+ });
15
+
16
+ const KEY = '_partition';
17
+
18
+ function mkClient(write, reads) {
19
+ const client = create(server.host, String(server.port), {
20
+ useIam: false,
21
+ protocol: 'ws',
22
+ partition: { partitionKey: KEY, writePartition: write, readPartitions: reads }
23
+ });
24
+ onTestFinished(() => client.close());
25
+ return client;
26
+ }
27
+
28
+ describe('partition strategy', () => {
29
+ it('isolates writes to writePartition and gates reads on readPartitions', async () => {
30
+ const a = `a-${randomUUID()}`;
31
+ const b = `b-${randomUUID()}`;
32
+ const name = `iso-${randomUUID()}`;
33
+
34
+ const writerA = mkClient(a, [a]);
35
+ const readerA = mkClient(a, [a]);
36
+ const readerB = mkClient(b, [b]);
37
+
38
+ await writerA.query(g => g.addV('Widget').property('name', name).next());
39
+
40
+ const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
41
+ expect(fromB).toEqual([]);
42
+
43
+ const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
44
+ expect(fromA.length).toBe(1);
45
+ });
46
+
47
+ it('lets readPartitions span multiple partitions', async () => {
48
+ const a = `a-${randomUUID()}`;
49
+ const shared = `shared-${randomUUID()}`;
50
+ const nameA = `multi-a-${randomUUID()}`;
51
+ const nameShared = `multi-s-${randomUUID()}`;
52
+
53
+ const writerA = mkClient(a, [a]);
54
+ const writerShared = mkClient(shared, [shared]);
55
+ const readerBoth = mkClient(a, [a, shared]);
56
+
57
+ await writerA.query(g => g.addV('Widget').property('name', nameA).next());
58
+ await writerShared.query(g => g.addV('Widget').property('name', nameShared).next());
59
+
60
+ const fromA = await readerBoth.query(g => g.V().has('Widget', 'name', nameA).toList());
61
+ const fromShared = await readerBoth.query(g => g.V().has('Widget', 'name', nameShared).toList());
62
+ expect(fromA.length).toBe(1);
63
+ expect(fromShared.length).toBe(1);
64
+ });
65
+
66
+ it('preserves the partition strategy across a reconnect', async () => {
67
+ const a = `a-${randomUUID()}`;
68
+ const b = `b-${randomUUID()}`;
69
+ const name = `reconnect-${randomUUID()}`;
70
+
71
+ const writerA = mkClient(a, [a]);
72
+ const readerA = mkClient(a, [a]);
73
+ const readerB = mkClient(b, [b]);
74
+
75
+ let calls = 0;
76
+ await writerA.query(g => {
77
+ calls++;
78
+ if (calls === 1) {
79
+ // Triggers index.js's reconnect path: close + rebuild g via
80
+ // createGraphTraversalSource. If the strategy is dropped on
81
+ // rebuild, the write below leaks into other partitions.
82
+ throw new Error('WebSocket is not open: readyState 3 (CLOSED)');
83
+ }
84
+ return g.addV('Widget').property('name', name).next();
85
+ });
86
+ expect(calls).toBe(2);
87
+
88
+ const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
89
+ expect(fromB).toEqual([]);
90
+
91
+ const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
92
+ expect(fromA.length).toBe(1);
93
+ });
94
+ });
95
+
96
+ describe('end-to-end query', () => {
97
+ function plainClient() {
98
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
99
+ onTestFinished(() => client.close());
100
+ return client;
101
+ }
102
+
103
+ it('round-trips a vertex through bytecode + GraphSON', async () => {
104
+ const client = plainClient();
105
+ const name = `e2e-${randomUUID()}`;
106
+
107
+ await client.query(g => g.addV('Widget').property('name', name).property('count', 7).next());
108
+
109
+ const fetched = await client.query(g =>
110
+ g.V().has('Widget', 'name', name).valueMap(true).toList()
111
+ );
112
+
113
+ expect(fetched.length).toBe(1);
114
+ const v = fetched[0];
115
+ expect(v.get('name')).toEqual([name]);
116
+ expect(v.get('count')).toEqual([7]);
117
+ });
118
+ });
119
+
120
+ describe('connection lifecycle', () => {
121
+ it('reuses one connection across multiple queries, then rebuilds after close()', async () => {
122
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
123
+ onTestFinished(() => client.close());
124
+
125
+ for (let i = 0; i < 3; i++) {
126
+ const out = await client.query(g => g.inject(i).next());
127
+ expect(out.value).toBe(i);
128
+ }
129
+
130
+ await client.close();
131
+ await client.close();
132
+
133
+ const out = await client.query(g => g.inject('after-reopen').next());
134
+ expect(out.value).toBe('after-reopen');
135
+ });
136
+ });
137
+
138
+ describe('server-side errors', () => {
139
+ it('bails immediately on a malformed traversal (no retry)', async () => {
140
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
141
+ onTestFinished(() => client.close());
142
+
143
+ let calls = 0;
144
+ await expect(
145
+ client.query(g => {
146
+ calls++;
147
+ // addE() without from/to context is invalid at execution time
148
+ // on the server; the resulting error doesn't match any
149
+ // RETRYABLE_ERRORS entry, so we expect a single attempt.
150
+ return g.addE('orphan').next();
151
+ })
152
+ ).rejects.toThrow();
153
+ expect(calls).toBe(1);
154
+ });
155
+ });