Skip to content

Commit 402f9b9

Browse files
committed
feat: exception control, timeouts, and type-level tests
Network failures (connection refused, DNS) are now wrapped in SendDockError with status 0 and retried instead of leaking a raw TypeError; a 2xx with a non-JSON body throws a typed error instead of a SyntaxError; every request carries an abort signal with a configurable timeout (default 30s) so a hung connection cannot block forever; a hostile Retry-After is capped at 60s. Tests grow from 14 to 34: constructor validation, network retry and timeout paths, malformed server responses, retry exhaustion, plus a compile-time contract suite (vitest typecheck) asserting that invalid send/batch/broadcast/import bodies fail to compile and response unions narrow correctly.
1 parent d66d600 commit 402f9b9

6 files changed

Lines changed: 290 additions & 2 deletions

File tree

src/client.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class SendDock {
2020
private readonly apiKey: string
2121
private readonly projectId: string
2222
private readonly maxRetries: number
23+
private readonly timeoutMs: number
2324
private readonly fetchImpl: typeof globalThis.fetch
2425

2526
constructor(options: SendDockOptions) {
@@ -30,6 +31,7 @@ export class SendDock {
3031
this.apiKey = options.apiKey
3132
this.projectId = options.projectId
3233
this.maxRetries = options.maxRetries ?? 2
34+
this.timeoutMs = options.timeoutMs ?? 30_000
3335
this.fetchImpl = options.fetch ?? globalThis.fetch
3436
}
3537

@@ -72,10 +74,31 @@ export class SendDock {
7274

7375
let lastError: SendDockError | undefined
7476
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
75-
const response = await this.fetchImpl(url, { method, headers, body: payload })
77+
let response: Response
78+
try {
79+
response = await this.fetchImpl(url, {
80+
method,
81+
headers,
82+
body: payload,
83+
signal: AbortSignal.timeout(this.timeoutMs),
84+
})
85+
} catch (cause) {
86+
lastError = networkError(cause, this.timeoutMs)
87+
if (attempt === this.maxRetries) throw lastError
88+
await sleep(500 * 2 ** attempt)
89+
continue
90+
}
7691

7792
if (response.ok) {
78-
return (await response.json()) as T
93+
try {
94+
return (await response.json()) as T
95+
} catch (cause) {
96+
throw new SendDockError(
97+
response.status,
98+
'the server returned a non-JSON response body',
99+
cause,
100+
)
101+
}
79102
}
80103

81104
const error = await this.toError(response)
@@ -104,6 +127,14 @@ export class SendDock {
104127
}
105128
}
106129

130+
function networkError(cause: unknown, timeoutMs: number): SendDockError {
131+
if (cause instanceof DOMException && cause.name === 'TimeoutError') {
132+
return new SendDockError(0, `request timed out after ${timeoutMs}ms`, cause)
133+
}
134+
const detail = cause instanceof Error ? cause.message : String(cause)
135+
return new SendDockError(0, `network error: ${detail}`, cause)
136+
}
137+
107138
function retryDelayMs(response: Response, attempt: number): number {
108139
const retryAfter = Number(response.headers.get('Retry-After'))
109140
if (Number.isFinite(retryAfter) && retryAfter > 0) {

src/error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export class SendDockError extends Error {
1717
return this.status === 429
1818
}
1919

20+
get isNetworkError(): boolean {
21+
return this.status === 0
22+
}
23+
2024
get isNotFound(): boolean {
2125
return this.status === 404
2226
}

src/exceptions.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { SendDock } from './client.js'
3+
import { SendDockError } from './error.js'
4+
5+
function jsonResponse(status: number, body: unknown, headers?: Record<string, string>) {
6+
return new Response(JSON.stringify(body), {
7+
status,
8+
headers: { 'Content-Type': 'application/json', ...headers },
9+
})
10+
}
11+
12+
function makeClient(fetchImpl: typeof fetch, maxRetries = 0) {
13+
return new SendDock({
14+
baseUrl: 'https://mail.example.com',
15+
apiKey: 'sk_test_123',
16+
projectId: 'proj-1',
17+
fetch: fetchImpl,
18+
maxRetries,
19+
})
20+
}
21+
22+
describe('constructor validation', () => {
23+
it.each([
24+
['baseUrl', { baseUrl: '', apiKey: 'sk', projectId: 'p' }],
25+
['apiKey', { baseUrl: 'https://x.com', apiKey: '', projectId: 'p' }],
26+
['projectId', { baseUrl: 'https://x.com', apiKey: 'sk', projectId: '' }],
27+
])('throws synchronously when %s is missing', (_field, opts) => {
28+
expect(() => new SendDock(opts)).toThrow()
29+
})
30+
31+
it('normalizes a trailing slash in baseUrl', async () => {
32+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, {}))
33+
const sd = new SendDock({
34+
baseUrl: 'https://mail.example.com///',
35+
apiKey: 'sk',
36+
projectId: 'p',
37+
fetch: fetchMock,
38+
})
39+
await sd.stats()
40+
expect(fetchMock.mock.calls[0]![0]).toBe(
41+
'https://mail.example.com/api/v1/projects/p/stats',
42+
)
43+
})
44+
})
45+
46+
describe('network failures', () => {
47+
it('wraps a connection failure in SendDockError instead of leaking a raw TypeError', async () => {
48+
const fetchMock = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
49+
const sd = makeClient(fetchMock)
50+
51+
const err = await sd.stats().catch((e: unknown) => e)
52+
53+
expect(err).toBeInstanceOf(SendDockError)
54+
expect((err as SendDockError).status).toBe(0)
55+
expect((err as SendDockError).isNetworkError).toBe(true)
56+
expect((err as SendDockError).message).toContain('fetch failed')
57+
})
58+
59+
it('retries network failures before giving up', async () => {
60+
vi.useFakeTimers()
61+
try {
62+
const fetchMock = vi
63+
.fn()
64+
.mockRejectedValueOnce(new TypeError('fetch failed'))
65+
.mockResolvedValueOnce(jsonResponse(200, { message: 'sent' }))
66+
const sd = makeClient(fetchMock, 2)
67+
68+
const pending = sd.send({ to: 'a@example.com', template_id: 't-1' })
69+
await vi.advanceTimersByTimeAsync(500)
70+
71+
expect(await pending).toEqual({ message: 'sent' })
72+
expect(fetchMock).toHaveBeenCalledTimes(2)
73+
} finally {
74+
vi.useRealTimers()
75+
}
76+
})
77+
78+
it('reports a timeout as a SendDockError naming the limit', async () => {
79+
const fetchMock = vi
80+
.fn()
81+
.mockRejectedValue(new DOMException('signal timed out', 'TimeoutError'))
82+
const sd = makeClient(fetchMock)
83+
84+
const err = await sd.stats().catch((e: unknown) => e)
85+
86+
expect(err).toBeInstanceOf(SendDockError)
87+
expect((err as SendDockError).message).toBe('request timed out after 30000ms')
88+
})
89+
90+
it('passes an abort signal so hung requests cannot hang forever', async () => {
91+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, {}))
92+
const sd = makeClient(fetchMock)
93+
94+
await sd.stats()
95+
96+
const [, init] = fetchMock.mock.calls[0]!
97+
expect(init.signal).toBeInstanceOf(AbortSignal)
98+
})
99+
})
100+
101+
describe('malformed server responses', () => {
102+
it('wraps invalid JSON on a 2xx instead of leaking a SyntaxError', async () => {
103+
const fetchMock = vi
104+
.fn()
105+
.mockResolvedValue(new Response('<html>gateway</html>', { status: 200 }))
106+
const sd = makeClient(fetchMock)
107+
108+
const err = await sd.stats().catch((e: unknown) => e)
109+
110+
expect(err).toBeInstanceOf(SendDockError)
111+
expect((err as SendDockError).message).toBe('the server returned a non-JSON response body')
112+
})
113+
114+
it('handles a non-JSON error body without crashing', async () => {
115+
const fetchMock = vi
116+
.fn()
117+
.mockResolvedValue(new Response('Bad Gateway', { status: 502 }))
118+
const sd = makeClient(fetchMock)
119+
120+
const err = await sd.stats().catch((e: unknown) => e)
121+
122+
expect(err).toBeInstanceOf(SendDockError)
123+
expect((err as SendDockError).status).toBe(502)
124+
expect((err as SendDockError).message).toBe('request failed with status 502')
125+
})
126+
127+
it('handles an error body whose error field is not a string', async () => {
128+
const fetchMock = vi
129+
.fn()
130+
.mockResolvedValue(jsonResponse(400, { error: { nested: true } }))
131+
const sd = makeClient(fetchMock)
132+
133+
const err = await sd.stats().catch((e: unknown) => e)
134+
135+
expect((err as SendDockError).message).toBe('request failed with status 400')
136+
expect((err as SendDockError).body).toEqual({ error: { nested: true } })
137+
})
138+
})
139+
140+
describe('retry exhaustion', () => {
141+
it('throws the last error after exhausting retries on 5xx', async () => {
142+
vi.useFakeTimers()
143+
try {
144+
const fetchMock = vi
145+
.fn()
146+
.mockImplementation(() => Promise.resolve(jsonResponse(503, { error: 'unavailable' })))
147+
const sd = makeClient(fetchMock, 2)
148+
149+
const pending = sd.stats().catch((e: unknown) => e)
150+
await vi.advanceTimersByTimeAsync(10_000)
151+
const err = await pending
152+
153+
expect(fetchMock).toHaveBeenCalledTimes(3)
154+
expect((err as SendDockError).status).toBe(503)
155+
expect((err as SendDockError).message).toBe('unavailable')
156+
} finally {
157+
vi.useRealTimers()
158+
}
159+
})
160+
161+
it('caps a hostile Retry-After at 60 seconds', async () => {
162+
vi.useFakeTimers()
163+
try {
164+
const fetchMock = vi
165+
.fn()
166+
.mockResolvedValueOnce(
167+
jsonResponse(429, { error: 'slow down' }, { 'Retry-After': '86400' }),
168+
)
169+
.mockResolvedValueOnce(jsonResponse(200, {}))
170+
const sd = makeClient(fetchMock, 1)
171+
172+
const pending = sd.stats()
173+
await vi.advanceTimersByTimeAsync(60_000)
174+
175+
await expect(pending).resolves.toEqual({})
176+
expect(fetchMock).toHaveBeenCalledTimes(2)
177+
} finally {
178+
vi.useRealTimers()
179+
}
180+
})
181+
182+
it('never retries 4xx client errors even with retries configured', async () => {
183+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(404, { error: 'project not found' }))
184+
const sd = makeClient(fetchMock, 3)
185+
186+
const err = await sd.stats().catch((e: unknown) => e)
187+
188+
expect(fetchMock).toHaveBeenCalledTimes(1)
189+
expect((err as SendDockError).isNotFound).toBe(true)
190+
})
191+
})

src/types.test-d.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { SendDock } from './client.js'
3+
import type { ImportResponse, SendResponse, Stats } from './types.js'
4+
5+
declare const sd: SendDock
6+
7+
describe('compile-time contract', () => {
8+
it('accepts the three valid send shapes', () => {
9+
expectTypeOf(sd.send({ to: 'a@b.co', template_id: 't' })).resolves.toEqualTypeOf<SendResponse>()
10+
expectTypeOf(sd.send({ subscriber_id: 's', template_id: 't' })).resolves.toEqualTypeOf<SendResponse>()
11+
expectTypeOf(sd.send({ to: 'a@b.co', subject: 'x', html_body: '<p>x</p>' })).resolves.toEqualTypeOf<SendResponse>()
12+
})
13+
14+
it('rejects invalid send bodies at compile time', () => {
15+
// @ts-expect-error a recipient alone is not a valid send shape
16+
void sd.send({ to: 'a@b.co' })
17+
// @ts-expect-error raw html requires a subject
18+
void sd.send({ to: 'a@b.co', html_body: '<p>x</p>' })
19+
// @ts-expect-error empty body is not a valid send shape
20+
void sd.send({})
21+
})
22+
23+
it('rejects invalid batch and broadcast bodies at compile time', () => {
24+
// @ts-expect-error recipients is required
25+
void sd.sendBatch({ template_id: 't' })
26+
// @ts-expect-error recipients entries need a to address
27+
void sd.sendBatch({ template_id: 't', recipients: [{ data: {} }] })
28+
// @ts-expect-error template_id is required
29+
void sd.broadcast({ segment_id: 's' })
30+
})
31+
32+
it('rejects invalid import rows at compile time', () => {
33+
// @ts-expect-error email is required on every row
34+
void sd.importSubscribers([{ name: 'Ada' }])
35+
// @ts-expect-error status is a closed union
36+
void sd.importSubscribers([{ email: 'a@b.co', status: 'banned' }])
37+
})
38+
39+
it('types the responses precisely', () => {
40+
expectTypeOf(sd.stats()).resolves.toEqualTypeOf<Stats>()
41+
expectTypeOf(sd.importSubscribers([{ email: 'a@b.co' }])).resolves.toEqualTypeOf<ImportResponse>()
42+
})
43+
44+
it('narrows the send response union on the suppressed discriminant', async () => {
45+
const result = await sd.send({ to: 'a@b.co', template_id: 't' })
46+
if ('message' in result && result.message === 'suppressed') {
47+
expectTypeOf(result.suppressed).toEqualTypeOf<number>()
48+
}
49+
if ('sent' in result) {
50+
expectTypeOf(result.sent).toEqualTypeOf<number>()
51+
expectTypeOf(result.suppressed).toEqualTypeOf<number | undefined>()
52+
}
53+
})
54+
})

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface SendDockOptions {
33
apiKey: string
44
projectId: string
55
maxRetries?: number
6+
timeoutMs?: number
67
fetch?: typeof globalThis.fetch
78
}
89

vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
typecheck: { enabled: true },
6+
},
7+
})

0 commit comments

Comments
 (0)