@vida-global/core 2.3.6 → 2.4.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.
@@ -54,7 +54,7 @@ const response = await client.getTickets({ page: 1, pageSize: 25 });
54
54
  | `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
55
55
  | `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
56
56
 
57
- For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body (serialized as JSON by default).
57
+ For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body. Object bodies are serialized as JSON; string bodies are sent unchanged.
58
58
 
59
59
 
60
60
  ### Query string encoding
@@ -37,7 +37,7 @@ class HttpClient {
37
37
 
38
38
  endpoint = `${this.urlRoot}${endpoint}`;
39
39
  const url = new URL(endpoint);
40
- const requestData = structuredClone(requestParams || body || {});
40
+ const requestData = structuredClone(requestParams ?? body ?? {});
41
41
  const payload = this.#prepareRequestPayload(url, requestData, headers, method, timeout, signal);
42
42
 
43
43
  try {
@@ -124,7 +124,7 @@ class HttpClient {
124
124
 
125
125
  #addRequestData(payload, requestData, method, url, headers) {
126
126
  if (method == "POST" || method == "PUT") {
127
- payload.body = JSON.stringify(requestData || {});
127
+ payload.body = typeof requestData == 'string' ? requestData : JSON.stringify(requestData);
128
128
  } else {
129
129
  this.#addUrlRequestData(url, requestData, headers);
130
130
  }
@@ -53,6 +53,9 @@ Inside `run()`:
53
53
  - `this.id` — UUID for the job.
54
54
  - `this.logger` — child logger tagged with the job's id.
55
55
  - `await this.updateProgress(value)` — pushes progress to BullMQ for observation.
56
+ - `this.attempt`, `this.maxAttempts`, `this.isFinalAttempt` — which try this is. Use
57
+ `isFinalAttempt` when a failure needs cleaning up once the retries are spent, rather than on every
58
+ attempt. Run outside a worker they read as a single final attempt, since nothing will retry.
56
59
 
57
60
  Enqueue a job with `queueJob`:
58
61
 
@@ -107,12 +110,48 @@ class CatchAllJob extends AbstractJob {
107
110
  ```
108
111
 
109
112
 
110
- ## Concurrency
113
+ ## Concurrency, queue maximums and rate limits
111
114
 
112
- A single worker can process multiple jobs in parallel. Set the `WORKER_CONCURRENCY` environment variable to raise the limit (default `1`).
115
+ ```js
116
+ const worker = new JobQueue.Worker('myQueue', {
117
+ concurrency: 5, // jobs at once in THIS worker
118
+ maximumConcurrency: 8, // jobs at once across EVERY worker on the queue
119
+ rateLimit: { max: 100, duration: 60000 } // jobs per period across every worker
120
+ });
121
+ ```
122
+
123
+ **`concurrency`** is per worker. Two processes at 5 each run 10 at once. It falls back to the
124
+ `WORKER_CONCURRENCY` environment variable, then to 1, so a process running a single worker can be
125
+ configured without touching code.
126
+
127
+ **`maximumConcurrency`** is the cap across every worker on the queue, enforced by Redis, so it does
128
+ what concurrency alone cannot. It is applied on `listen()` and **persists** — a worker started
129
+ without one clears it, so the value in Redis always matches the code running.
130
+
131
+ **`rateLimit`** is also enforced queue-wide, through a Redis key on the queue. Every worker on a
132
+ queue therefore shares one limit, and two workers passing different values will fight — the last to
133
+ start wins. A job that needs its own rate belongs on its own queue. There is no per-job or per-group
134
+ rate limiting in the open source BullMQ; that is a Pro feature.
135
+
136
+ Both `maximumConcurrency` and `rateLimit` are logged when applied, so the value in force is visible
137
+ in the worker's own output.
138
+
139
+
140
+ ## Shutting down
113
141
 
114
- ```sh
115
- WORKER_CONCURRENCY=10 node bin/worker.js
142
+ `close()` waits for jobs already in flight before resolving, which is what stops a deploy killing a
143
+ job halfway through. Core installs no signal handlers — the application owns the process:
144
+
145
+ ```js
146
+ const worker = new JobQueue.Worker('myQueue');
147
+ worker.listen();
148
+
149
+ for (const signal of ['SIGTERM', 'SIGINT']) {
150
+ process.on(signal, async () => {
151
+ await worker.close();
152
+ process.exit(0);
153
+ });
154
+ }
116
155
  ```
117
156
 
118
157
 
@@ -130,27 +169,27 @@ await queue.close(); // close the connection (call before process exit)
130
169
  await queue.pause(); // stop dispatching work
131
170
  await queue.resume(); // resume after pause
132
171
 
133
- await queue.numQueuedJobs(); // number of jobs waiting
172
+ await queue.getJobCounts(); // { waiting, active, completed, failed, delayed, paused }
173
+ await queue.numWorkers(); // number of workers currently attached
174
+
134
175
  await queue.getQueuedJobs(); // formatted snapshot of waiting jobs
135
- await queue.numActiveJobs();
136
176
  await queue.getActiveJobs();
137
- await queue.numFailedJobs();
138
177
  await queue.getFailedJobs();
178
+ await queue.getJob(id); // one job, or null
139
179
 
140
- await queue.clearFailedJobs(); // drop failed-job data (do this periodically)
141
- await queue.clearCompletedJobs(); // drop completed-job data
142
- await queue.clearQueuedJobs(); // drain all queued jobs
180
+ await queue.clean(grace, limit, type); // remove finished jobs older than `grace` ms
181
+ await queue.clearQueuedJobs(); // drain all queued jobs
143
182
 
144
- await queue.numWorkers(); // number of workers currently attached
183
+ await queue.setMaximumConcurrency(8); // usually left to the Worker option above
184
+ await queue.maximumConcurrency();
185
+ await queue.removeMaximumConcurrency();
145
186
  ```
146
187
 
147
- Each `get*Jobs` result entry has this shape:
188
+ `clean` takes BullMQ's argument order, which is `(grace, limit, type)`. Bull's was
189
+ `(grace, type, limit)` — worth checking when porting a call.
190
+
191
+ Each `get*Jobs` and `getJob` result has this shape:
148
192
 
149
193
  ```js
150
194
  { queueName, name, args, id, attemptsMade, attemptsStarted, progress, failedReason }
151
195
  ```
152
-
153
-
154
- ## TODO
155
-
156
- - **Rate limiting.** Pre-job rate limiting is not yet wired up.
@@ -7,9 +7,16 @@ const DEFAULT_NUM_RETRIES = 3;
7
7
  const DEFAULT_RETRY_BACKOFF_TYPE = 'exponential';
8
8
  const DEFAULT_RETRY_BACKOFF_DELAY = 2000;
9
9
 
10
+ // BullMQ keeps every finished job record forever unless told otherwise, so without these Redis
11
+ // grows without bound. A bounded window keeps recent history inspectable and lets the rest go.
12
+ const DEFAULT_KEEP_COMPLETED = { age: 86400, count: 1000 }; // a day, or the last thousand
13
+ const DEFAULT_KEEP_FAILED = { age: 604800 }; // a week
14
+
10
15
 
11
16
  class AbstractJob {
17
+ #attempt;
12
18
  #id;
19
+ #maxAttempts;
13
20
  #updateProgress;
14
21
 
15
22
 
@@ -36,9 +43,11 @@ class AbstractJob {
36
43
  }
37
44
 
38
45
 
39
- async _run(id, args, updateProgress) {
46
+ async _run(id, args, updateProgress, { attempt = 1, maxAttempts = 1 } = {}) {
40
47
  this.#id = id;
41
48
  this.#updateProgress = updateProgress;
49
+ this.#attempt = attempt;
50
+ this.#maxAttempts = maxAttempts;
42
51
  await this.run(...args);
43
52
  }
44
53
 
@@ -48,6 +57,21 @@ class AbstractJob {
48
57
  }
49
58
 
50
59
 
60
+ get attempt() {
61
+ return this.#attempt;
62
+ }
63
+
64
+
65
+ get maxAttempts() {
66
+ return this.#maxAttempts;
67
+ }
68
+
69
+
70
+ get isFinalAttempt() {
71
+ return this.#attempt >= this.#maxAttempts;
72
+ }
73
+
74
+
51
75
  get loggerLibrary() {
52
76
  return logger;
53
77
  }
@@ -59,7 +83,7 @@ class AbstractJob {
59
83
 
60
84
 
61
85
  get logger() {
62
- return this.loggerLibrary[this.constructor.name].child(this.id);
86
+ return this.loggerLibrary.scope(this.constructor.name).child(this.id);
63
87
  }
64
88
 
65
89
 
@@ -90,7 +114,9 @@ class AbstractJob {
90
114
  backoff: {
91
115
  type: this.retryBackoffType,
92
116
  delay: this.retryBackoffDelay
93
- }
117
+ },
118
+ removeOnComplete: this.keepCompleted,
119
+ removeOnFail: this.keepFailed
94
120
  }
95
121
 
96
122
  return settings;
@@ -102,6 +128,16 @@ class AbstractJob {
102
128
  }
103
129
 
104
130
 
131
+ static get keepCompleted() {
132
+ return DEFAULT_KEEP_COMPLETED;
133
+ }
134
+
135
+
136
+ static get keepFailed() {
137
+ return DEFAULT_KEEP_FAILED;
138
+ }
139
+
140
+
105
141
  static get retryBackoffType() {
106
142
  return DEFAULT_RETRY_BACKOFF_TYPE;
107
143
  }
@@ -1,7 +1,6 @@
1
1
  const { AbstractJobComponent } = require('./abstractJobComponent');
2
2
  const BullMQ = require('bullmq');
3
3
  const { logger } = require('../logger');
4
- const { randomUUID } = require('crypto');
5
4
 
6
5
 
7
6
  logger.addScope('queue');
@@ -22,12 +21,10 @@ class Queue extends AbstractJobComponent {
22
21
  get loggerLibrary() { return logger }
23
22
  get logger() { return this.loggerLibrary.queue.child(this.fullQueueName) }
24
23
  get queueClass() { return BullMQ.Queue }
25
- get idGenerator() { return randomUUID }
26
24
 
27
25
 
28
26
  async queueJob(job, args, settings) {
29
- const id = this.idGenerator();
30
- await this.#queue.add(job.name, { id, args }, settings);
27
+ await this.#queue.add(job.name, { args }, settings);
31
28
  }
32
29
 
33
30
 
@@ -67,11 +64,29 @@ class Queue extends AbstractJobComponent {
67
64
  }
68
65
 
69
66
 
67
+ /***********************************************************************************************
68
+ * LIMITS
69
+ ***********************************************************************************************/
70
+ async setMaximumConcurrency(maximum) {
71
+ await this.#queue.setGlobalConcurrency(maximum);
72
+ }
73
+
74
+
75
+ async maximumConcurrency() {
76
+ return await this.#queue.getGlobalConcurrency();
77
+ }
78
+
79
+
80
+ async removeMaximumConcurrency() {
81
+ await this.#queue.removeGlobalConcurrency();
82
+ }
83
+
84
+
70
85
  /***********************************************************************************************
71
86
  * JOBS
72
87
  ***********************************************************************************************/
73
- async numQueuedJobs() {
74
- return await this.#queue.count();
88
+ async getJobCounts() {
89
+ return await this.#queue.getJobCounts();
75
90
  }
76
91
 
77
92
 
@@ -81,35 +96,28 @@ class Queue extends AbstractJobComponent {
81
96
  }
82
97
 
83
98
 
84
- async numActiveJobs() {
85
- return await this.#queue.getActiveCount();
86
- }
87
-
88
-
89
99
  async getActiveJobs() {
90
100
  const jobs = await this.#queue.getActive();
91
101
  return this.#_formatJobsData(jobs);
92
102
  }
93
103
 
94
104
 
95
- async numFailedJobs() {
96
- return await this.#queue.getFailedCount();
97
- }
98
-
99
-
100
105
  async getFailedJobs() {
101
106
  const jobs = await this.#queue.getFailed();
102
107
  return this.#_formatJobsData(jobs);
103
108
  }
104
109
 
105
110
 
106
- async clearFailedJobs() {
107
- await this.#queue.clean(0, null, "failed");
111
+ async getJob(id) {
112
+ const job = await this.#queue.getJob(id);
113
+ return job ? this.#_formatJobData(job) : null;
108
114
  }
109
115
 
110
116
 
111
- async clearCompletedJobs() {
112
- await this.#queue.clean(0, null, "completed");
117
+ // `grace` is in milliseconds and only jobs older than that are removed. Note the argument order
118
+ // differs from bull's `clean(grace, type, limit)`.
119
+ async clean(grace, limit, type) {
120
+ return await this.#queue.clean(grace, limit, type);
113
121
  }
114
122
 
115
123
 
@@ -1,6 +1,7 @@
1
1
  const APM = require('../apm');
2
2
  const { AbstractJobComponent } = require('./abstractJobComponent');
3
3
  const BullMQ = require('bullmq');
4
+ const { getQueue } = require('./queue');
4
5
  const { JobImporter } = require('./jobImporter');
5
6
  const { logger } = require('../logger');
6
7
  const { randomUUID } = require('crypto');
@@ -21,36 +22,51 @@ let registeredJobs;
21
22
 
22
23
  class Worker extends AbstractJobComponent {
23
24
  #defaultJob;
25
+ #options;
24
26
  #queueName;
25
27
  #_worker;
26
28
 
27
29
 
28
- constructor(queueName) {
30
+ constructor(queueName, options = {}) {
29
31
  super();
30
32
  this.#queueName = queueName || process.env.WORKER_QUEUE_NAME;
33
+ this.#options = options;
31
34
  }
32
35
 
33
36
 
34
37
  async listen() {
35
38
  await this.#registerJobs();
39
+ await this.#applyQueueLimits();
36
40
  this.logger.info(`Worker is listening on queue ${this.fullQueueName}`);
37
41
  await this.#worker.run();
38
42
  }
39
43
 
40
44
 
45
+ // Waits for jobs already in flight. Core installs no signal handlers of its own — the
46
+ // application owns the process and decides when this is called.
47
+ async close() {
48
+ await this.#worker.close();
49
+ }
50
+
51
+
41
52
  #registerJobs() {
42
- if (registeredJobs) return;
53
+ registeredJobs ||= this.#importJobs();
54
+
55
+ this.#defaultJob = Object.values(registeredJobs)
56
+ .find(job => job.defaultFor == this.queueName) || null;
57
+ }
58
+
43
59
 
44
- const importer = new JobImporter(this.jobDirectories);
45
- const jobs = importer.jobs;
46
- registeredJobs = Object.fromEntries(jobs.map(job => [job.name, job]));
47
- this.#defaultJob = jobs.find(job => job.defaultFor == this.queueName) || null;
60
+ #importJobs() {
61
+ const jobs = new JobImporter(this.jobDirectories).jobs;
48
62
 
49
63
  if (process.env.NODE_ENV != 'test') {
50
64
  for (const job of jobs) {
51
65
  this.logger.verbose(`JOB: ${job.name}`);
52
66
  }
53
67
  }
68
+
69
+ return Object.fromEntries(jobs.map(job => [job.name, job]));
54
70
  }
55
71
 
56
72
 
@@ -100,13 +116,24 @@ class Worker extends AbstractJobComponent {
100
116
  concurrency: this.concurrency,
101
117
  connection: this.redisConnectionDetails
102
118
  };
119
+ if (this.rateLimit) options.limiter = this.rateLimit;
103
120
 
104
121
  return options;
105
122
  }
106
123
 
107
124
 
125
+ // How many jobs this one worker runs at once. The constructor option wins so several workers in
126
+ // a process can differ; the environment variable is the default for any that says nothing.
108
127
  get concurrency() {
109
- return parseInt(process.env.WORKER_CONCURRENCY || 1);
128
+ return parseInt(this.#options.concurrency || process.env.WORKER_CONCURRENCY || 1);
129
+ }
130
+
131
+
132
+ // `{max, duration}`. BullMQ enforces this through a Redis key on the queue, so it is shared by
133
+ // every worker listening on it — two workers with different values fight and the last to start
134
+ // wins. A job that needs its own rate gets its own queue.
135
+ get rateLimit() {
136
+ return this.#options.rateLimit || null;
110
137
  }
111
138
 
112
139
 
@@ -115,6 +142,28 @@ class Worker extends AbstractJobComponent {
115
142
  }
116
143
 
117
144
 
145
+ get queue() {
146
+ return getQueue(this.queueName);
147
+ }
148
+
149
+
150
+ async #applyQueueLimits() {
151
+ const maximumConcurrency = this.#options.maximumConcurrency ?? null;
152
+
153
+ if (maximumConcurrency === null) {
154
+ await this.queue.removeMaximumConcurrency();
155
+ } else {
156
+ await this.queue.setMaximumConcurrency(maximumConcurrency);
157
+ this.logger.info(`Queue ${this.fullQueueName} maximum concurrency is ${maximumConcurrency}`);
158
+ }
159
+
160
+ if (this.rateLimit) {
161
+ const { max, duration } = this.rateLimit;
162
+ this.logger.info(`Queue ${this.fullQueueName} rate limit is ${max} per ${duration}ms`);
163
+ }
164
+ }
165
+
166
+
118
167
  /***********************************************************************************************
119
168
  * PROCESS JOB
120
169
  ***********************************************************************************************/
@@ -130,13 +179,12 @@ class Worker extends AbstractJobComponent {
130
179
 
131
180
 
132
181
  #buildJobContext(bullJob, jobClass) {
133
- const { id, args } = this.#dataForJob(bullJob);
134
182
  return {
135
183
  bullJob,
136
184
  jobClass,
137
185
  jobName: jobClass.name,
138
- id,
139
- args,
186
+ id: bullJob.id,
187
+ args: this.#argsForJob(bullJob),
140
188
  queueName: this.queueName,
141
189
  attempt: bullJob.attemptsMade + 1,
142
190
  maxAttempts: bullJob.opts.attempts,
@@ -162,11 +210,14 @@ class Worker extends AbstractJobComponent {
162
210
 
163
211
 
164
212
  async #executeJob(context) {
165
- const { bullJob, jobClass, id, args, jobName } = context;
213
+ const { bullJob, jobClass, id, args, jobName, attempt, maxAttempts } = context;
166
214
  const job = new jobClass();
167
215
  await this.apm.startSegment(`job:${jobName}`,
168
216
  true,
169
- () => job._run(id, args, bullJob.updateProgress.bind(bullJob)));
217
+ () => job._run(id,
218
+ args,
219
+ bullJob.updateProgress.bind(bullJob),
220
+ { attempt, maxAttempts }));
170
221
  }
171
222
 
172
223
 
@@ -227,8 +278,8 @@ class Worker extends AbstractJobComponent {
227
278
  }
228
279
 
229
280
 
230
- #handleJobCompletion({ name, data }, duration) {
231
- const logger = this.#loggerForJob({ name, data });
281
+ #handleJobCompletion(job, duration) {
282
+ const logger = this.#loggerForJob(job);
232
283
  logger.debug(`Completed in ${duration}ms`);
233
284
  }
234
285
 
@@ -240,8 +291,6 @@ class Worker extends AbstractJobComponent {
240
291
 
241
292
 
242
293
  #handleJobFailure(job, err) {
243
- const log = `Job Failed (Attempt ${job.attemptsMade}/${job.opts.attempts}): ${job.name} - ${err}`;
244
-
245
294
  const lastRetry = job.attemptsMade == job.opts.attempts;
246
295
  const logger = this.#loggerForJob(job);
247
296
  logger.debug(`ERROR (${job.attemptsMade}/${job.opts.attempts}) ${err}`);
@@ -261,8 +310,7 @@ class Worker extends AbstractJobComponent {
261
310
 
262
311
 
263
312
  #loggerForJob(bullJob) {
264
- const { id } = this.#dataForJob(bullJob);
265
- return this.logger.child(id);
313
+ return this.logger.child(bullJob.id);
266
314
  }
267
315
 
268
316
 
@@ -272,12 +320,11 @@ class Worker extends AbstractJobComponent {
272
320
  }
273
321
 
274
322
 
275
- #dataForJob({ name, data }) {
323
+ #argsForJob({ name, data }) {
276
324
  if (name == '__default__') { // legacy for vida.live. Remove when vida.live has been fully migrated
277
- return { args: [data] };
325
+ return [data];
278
326
  } else {
279
- const { id, args } = data;
280
- return { id, args };
327
+ return data.args;
281
328
  }
282
329
  }
283
330
  }
@@ -55,6 +55,18 @@ logger.bar.error('my log');
55
55
  // [2026-04-30 16:16:12.797 -0700][ERROR][BAR] my log
56
56
  ```
57
57
 
58
+ `scope(name)` registers on first use and returns the logger, which is how a scope gets named after
59
+ something that is not lowercase, such as a class. The scope answers to both spellings afterwards:
60
+
61
+ ```js
62
+ logger.scope('FinalizeSiprecCall').info('my log');
63
+ // [2026-04-30 16:16:12.797 -0700][INFO][FINALIZESIPRECCALL] my log
64
+
65
+ logger.FinalizeSiprecCall === logger.finalizesipreccall; // true
66
+ ```
67
+
68
+ The tag is always the lowercased name, so log lines read the same however the scope was spelled.
69
+
58
70
  ### Child loggers with IDs
59
71
 
60
72
  `child(id)` returns a logger that tags every line with the given id. This is the standard pattern for per-request logging — the server framework already calls `logger.child(requestId)` for every controller instance.
@@ -56,9 +56,8 @@ class Logger {
56
56
  const scopes = this.scopes.map(scope => `[${scope.toUpperCase()}]`).join('');
57
57
  let prefix = `[${info.timestamp}][${level}]${scopes}`;
58
58
  if (id) prefix = `${prefix}[${id}]`;
59
- info.message = `${prefix} ${info.message}`;
60
59
 
61
- return info;
60
+ return { ...info, message: `${prefix} ${info.message}` };
62
61
  }
63
62
 
64
63
 
@@ -73,16 +72,33 @@ class Logger {
73
72
  }
74
73
 
75
74
 
75
+ // The scope is reachable both as it was given and lowercased, so a scope named after a class
76
+ // answers to `logger.FinalizeSiprecCall` and `logger.finalizesiprecall` alike.
76
77
  addScope(scope) {
77
- scope = scope.toLowerCase();
78
- const existing = this[scope];
78
+ const scopeName = scope.toLowerCase();
79
+ const existing = this[scopeName];
79
80
  if (existing) {
80
- if (!existing instanceof Logger) throw `Property already exists at ${scope}`;
81
- return;
81
+ if (!(existing instanceof Logger)) throw new Error(`Property already exists at ${scopeName}`);
82
+ return existing;
82
83
  }
83
84
 
84
- const logger = this.#createLogger(this.scopes.concat([scope]), this.id);
85
- Object.defineProperty(this, scope, { get: () => logger });
85
+ const logger = this.#createLogger(this.scopes.concat([scopeName]), this.id);
86
+ for (const name of [scopeName, scope]) this.#defineScope(name, logger);
87
+ return logger;
88
+ }
89
+
90
+
91
+ scope(name) {
92
+ return this.addScope(name);
93
+ }
94
+
95
+
96
+ #defineScope(name, logger) {
97
+ const existing = this[name];
98
+ if (existing instanceof Logger) return;
99
+ if (existing) throw new Error(`Property already exists at ${name}`);
100
+
101
+ Object.defineProperty(this, name, { get: () => logger });
86
102
  }
87
103
 
88
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.3.6",
3
+ "version": "2.4.0",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -109,6 +109,27 @@ describe('HttpClient', () => {
109
109
  expect.objectContaining({ body: JSON.stringify(body) }),
110
110
  );
111
111
  });
112
+
113
+ it('sends string request bodies without JSON serialization', async () => {
114
+ const body = 'firstname=Jane&lastname=Doe';
115
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
116
+
117
+ await client[methodName](Helpers.apiUrl, { body, headers });
118
+
119
+ expect(fetch).toHaveBeenCalledWith(
120
+ Helpers.apiUrl,
121
+ expect.objectContaining({ body, headers: expect.objectContaining(headers) }),
122
+ );
123
+ });
124
+
125
+ it('preserves empty string request bodies', async () => {
126
+ await client[methodName](Helpers.apiUrl, { body: '' });
127
+
128
+ expect(fetch).toHaveBeenCalledWith(
129
+ Helpers.apiUrl,
130
+ expect.objectContaining({ body: '' }),
131
+ );
132
+ });
112
133
  }
113
134
 
114
135
  if (behavior.supportsParams) {