Skip to content

Commit 24dfd69

Browse files
committed
merge main
2 parents 3862a81 + a4dcf98 commit 24dfd69

7 files changed

Lines changed: 196 additions & 62 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @danrowden @notnmeyer @askkaz

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: test
2+
3+
permissions:
4+
contents: read # required for actions/checkout
5+
6+
on:
7+
push:
8+
workflow_dispatch:
9+
10+
jobs:
11+
run:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v6
15+
- uses: actions/setup-node@v6
16+
with:
17+
node-version: 24
18+
- run: npm i
19+
- run: npm test

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ try {
3737
} catch (error) {
3838
if (error instanceof APIError) {
3939
// JSON returned by the API is in error.json and the HTTP code is in error.statusCode
40-
// Error messages explaining the issue can be found in error.json.message
40+
// error.json may be null if the response was not valid JSON (e.g., from a load balancer)
41+
// In that case, the raw response text is available in error.rawBody
4142
console.log(error.json);
4243
console.log(error.statusCode);
44+
console.log(error.rawBody);
4345
} else {
4446
// Non-API errors
4547
}
@@ -768,7 +770,8 @@ const resp = await loops.getTransactionalEmails({ perPage: 15 });
768770

769771
## Version history
770772

771-
- `v6.0.2` (Jan 15, 2026) - Updated `TransactionalVariables` type to support arrays of objects with `string` or `number` values in [`sendTransactionalEmail()`](#sendtransactionalemail).
773+
- `v6.1.1` (Feb 5, 2026) - Updated `TransactionalVariables` type to support arrays of objects with `string` or `number` values in [`sendTransactionalEmail()`](#sendtransactionalemail).
774+
- `v6.1.0` (Jan 29, 2026) - Added `rawBody` to `APIError` in the case no JSON is received from the server (thanks to [@leipert](https://github.com/leipert)).
772775
- `v6.0.1` (Oct 15, 2025) - Added `optInStatus` to contact object in [`findContact()`](#findcontact) for the new double opt-in feature.
773776
- `v6.0.0` (Aug 22, 2025) - [`createContact()`](#createcontact) and [`updateContact()`](#updatecontact) now have a single object parameter instead of named parameters (breaking change). This allows support for using either `email` or `userId` when updating contacts.
774777
- `v5.0.1` (May 13, 2025) - Added a `headers` parameter for [`sendEvent()`](#sendevent) and [`sendTransactionalEmail()`](#sendtransactionalemail), enabling support for the `Idempotency-Key` header.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "loops",
3-
"version": "6.0.1",
3+
"version": "6.1.2",
44
"author": "Dan Rowden <dan@loops.so>",
55
"license": "MIT",
66
"main": "./dist/index.cjs",

src/__tests__/LoopsClient.test.ts

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("LoopsClient", () => {
3131
const mockResponse = { success: true, teamName: "Test Team" };
3232
global.fetch = jest.fn().mockResolvedValue({
3333
ok: true,
34-
json: () => Promise.resolve(mockResponse),
34+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
3535
});
3636

3737
const result = await client.testApiKey();
@@ -48,7 +48,7 @@ describe("LoopsClient", () => {
4848
global.fetch = jest.fn().mockResolvedValue({
4949
ok: false,
5050
status: 401,
51-
json: () => Promise.resolve(mockResponse),
51+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
5252
});
5353

5454
await expect(client.testApiKey()).rejects.toThrow(APIError);
@@ -80,7 +80,7 @@ describe("LoopsClient", () => {
8080

8181
global.fetch = jest.fn().mockResolvedValue({
8282
ok: true,
83-
json: () => Promise.resolve(mockResponse),
83+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
8484
});
8585

8686
const result = await client.createContact({ email });
@@ -106,7 +106,7 @@ describe("LoopsClient", () => {
106106

107107
global.fetch = jest.fn().mockResolvedValue({
108108
ok: true,
109-
json: () => Promise.resolve(mockResponse),
109+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
110110
});
111111

112112
const result = await client.createContact({ email, properties });
@@ -131,7 +131,7 @@ describe("LoopsClient", () => {
131131
global.fetch = jest.fn().mockResolvedValue({
132132
ok: false,
133133
status: 400,
134-
json: () => Promise.resolve(mockResponse),
134+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
135135
});
136136

137137
await expect(client.createContact({ email })).rejects.toThrow(APIError);
@@ -165,7 +165,7 @@ describe("LoopsClient", () => {
165165

166166
global.fetch = jest.fn().mockResolvedValue({
167167
ok: true,
168-
json: () => Promise.resolve(mockResponse),
168+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
169169
});
170170

171171
const result = await client.updateContact({
@@ -198,7 +198,7 @@ describe("LoopsClient", () => {
198198

199199
global.fetch = jest.fn().mockResolvedValue({
200200
ok: true,
201-
json: () => Promise.resolve(mockResponse),
201+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
202202
});
203203

204204
const result = await client.updateContact({
@@ -227,7 +227,7 @@ describe("LoopsClient", () => {
227227
global.fetch = jest.fn().mockResolvedValue({
228228
ok: true,
229229
status: 200,
230-
json: () => Promise.resolve(mockResponse),
230+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
231231
});
232232

233233
const result = await client.updateContact({ email });
@@ -255,7 +255,7 @@ describe("LoopsClient", () => {
255255

256256
global.fetch = jest.fn().mockResolvedValue({
257257
ok: true,
258-
json: () => Promise.resolve(mockResponse),
258+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
259259
});
260260

261261
const result = await client.createContactProperty(name, type);
@@ -279,7 +279,7 @@ describe("LoopsClient", () => {
279279
global.fetch = jest.fn().mockResolvedValue({
280280
ok: false,
281281
status: 400,
282-
json: () => Promise.resolve(mockResponse),
282+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
283283
});
284284

285285
await expect(
@@ -304,7 +304,7 @@ describe("LoopsClient", () => {
304304
global.fetch = jest.fn().mockResolvedValue({
305305
ok: false,
306306
status: 400,
307-
json: () => Promise.resolve(mockResponse),
307+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
308308
});
309309

310310
await expect(client.createContactProperty(name, type)).rejects.toThrow(
@@ -341,7 +341,7 @@ describe("LoopsClient", () => {
341341

342342
global.fetch = jest.fn().mockResolvedValue({
343343
ok: true,
344-
json: () => Promise.resolve(mockResponse),
344+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
345345
});
346346

347347
const result = await client.sendEvent(eventData);
@@ -371,7 +371,7 @@ describe("LoopsClient", () => {
371371

372372
global.fetch = jest.fn().mockResolvedValue({
373373
ok: true,
374-
json: () => Promise.resolve(mockResponse),
374+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
375375
});
376376

377377
const result = await client.sendEvent(eventData);
@@ -412,7 +412,7 @@ describe("LoopsClient", () => {
412412
global.fetch = jest.fn().mockResolvedValue({
413413
ok: false,
414414
status: 400,
415-
json: () => Promise.resolve(mockResponse),
415+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
416416
});
417417

418418
await expect(client.sendEvent(eventData)).rejects.toThrow(APIError);
@@ -440,7 +440,7 @@ describe("LoopsClient", () => {
440440

441441
global.fetch = jest.fn().mockResolvedValue({
442442
ok: true,
443-
json: () => Promise.resolve(mockResponse),
443+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
444444
});
445445

446446
const result = await client.sendEvent(eventData);
@@ -476,7 +476,7 @@ describe("LoopsClient", () => {
476476

477477
global.fetch = jest.fn().mockResolvedValue({
478478
ok: true,
479-
json: () => Promise.resolve(mockResponse),
479+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
480480
});
481481

482482
const result = await client.sendEvent(eventData);
@@ -506,7 +506,7 @@ describe("LoopsClient", () => {
506506

507507
global.fetch = jest.fn().mockResolvedValue({
508508
ok: true,
509-
json: () => Promise.resolve(mockResponse),
509+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
510510
});
511511

512512
const result = await client.sendTransactionalEmail(emailData);
@@ -534,7 +534,7 @@ describe("LoopsClient", () => {
534534
global.fetch = jest.fn().mockResolvedValue({
535535
ok: false,
536536
status: 404,
537-
json: () => Promise.resolve(mockResponse),
537+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
538538
});
539539

540540
await expect(client.sendTransactionalEmail(emailData)).rejects.toThrow(
@@ -560,6 +560,103 @@ describe("LoopsClient", () => {
560560
});
561561
});
562562

563+
describe("non-JSON error responses", () => {
564+
it("should handle HTML error response", async () => {
565+
const htmlBody = "<html><body>502 Bad Gateway</body></html>";
566+
global.fetch = jest.fn().mockResolvedValue({
567+
ok: false,
568+
status: 502,
569+
headers: new Headers(),
570+
text: () => Promise.resolve(htmlBody),
571+
});
572+
573+
try {
574+
await client.testApiKey();
575+
fail("Should have thrown");
576+
} catch (error) {
577+
expect(error).toBeInstanceOf(APIError);
578+
expect((error as APIError).statusCode).toBe(502);
579+
expect((error as APIError).json).toBeNull();
580+
expect((error as APIError).rawBody).toBe(htmlBody);
581+
}
582+
});
583+
584+
it("should handle plain text error response", async () => {
585+
global.fetch = jest.fn().mockResolvedValue({
586+
ok: false,
587+
status: 503,
588+
headers: new Headers(),
589+
text: () => Promise.resolve("Service Unavailable"),
590+
});
591+
592+
try {
593+
await client.testApiKey();
594+
fail("Should have thrown");
595+
} catch (error) {
596+
expect(error).toBeInstanceOf(APIError);
597+
expect((error as APIError).statusCode).toBe(503);
598+
expect((error as APIError).rawBody).toBe("Service Unavailable");
599+
}
600+
});
601+
602+
it("should handle empty body error response", async () => {
603+
global.fetch = jest.fn().mockResolvedValue({
604+
ok: false,
605+
status: 500,
606+
headers: new Headers(),
607+
text: () => Promise.resolve(""),
608+
});
609+
610+
try {
611+
await client.testApiKey();
612+
fail("Should have thrown");
613+
} catch (error) {
614+
expect(error).toBeInstanceOf(APIError);
615+
expect((error as APIError).json).toBeNull();
616+
expect((error as APIError).rawBody).toBe("");
617+
}
618+
});
619+
620+
it("should still parse valid JSON errors normally", async () => {
621+
const jsonError = { success: false, message: "Invalid API key" };
622+
global.fetch = jest.fn().mockResolvedValue({
623+
ok: false,
624+
status: 401,
625+
headers: new Headers(),
626+
text: () => Promise.resolve(JSON.stringify(jsonError)),
627+
});
628+
629+
try {
630+
await client.testApiKey();
631+
fail("Should have thrown");
632+
} catch (error) {
633+
expect(error).toBeInstanceOf(APIError);
634+
expect((error as APIError).json).toEqual(jsonError);
635+
expect((error as APIError).rawBody).toBeUndefined();
636+
}
637+
});
638+
639+
it("should throw APIError when success response is not JSON", async () => {
640+
const htmlBody = "<html><body>OK</body></html>";
641+
global.fetch = jest.fn().mockResolvedValue({
642+
ok: true,
643+
status: 200,
644+
headers: new Headers(),
645+
text: () => Promise.resolve(htmlBody),
646+
});
647+
648+
try {
649+
await client.testApiKey();
650+
fail("Should have thrown");
651+
} catch (error) {
652+
expect(error).toBeInstanceOf(APIError);
653+
expect((error as APIError).statusCode).toBe(200);
654+
expect((error as APIError).json).toBeNull();
655+
expect((error as APIError).rawBody).toBe(htmlBody);
656+
}
657+
});
658+
});
659+
563660
describe("listTransactionalEmails", () => {
564661
it("should list transactional emails successfully", async () => {
565662
const mockTransactionalEmails = [
@@ -584,7 +681,7 @@ describe("LoopsClient", () => {
584681

585682
global.fetch = jest.fn().mockResolvedValue({
586683
ok: true,
587-
json: () => Promise.resolve(mockResponse),
684+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
588685
});
589686

590687
const result = await client.getTransactionalEmails();
@@ -620,7 +717,7 @@ describe("LoopsClient", () => {
620717

621718
global.fetch = jest.fn().mockResolvedValue({
622719
ok: true,
623-
json: () => Promise.resolve(mockResponse),
720+
text: () => Promise.resolve(JSON.stringify(mockResponse)),
624721
});
625722

626723
const result = await client.getTransactionalEmails();

0 commit comments

Comments
 (0)