|
1 | 1 | import { loggerMock } from '@sim/testing' |
| 2 | +import { DrizzleQueryError } from 'drizzle-orm/errors' |
2 | 3 | import { NextRequest, NextResponse } from 'next/server' |
3 | 4 | import { describe, expect, it, vi } from 'vitest' |
4 | 5 | import { HttpError } from '@/lib/core/utils/http-error' |
@@ -142,4 +143,89 @@ describe('withRouteHandler', () => { |
142 | 143 | await expect(response.json()).resolves.toEqual({ family: 'generic' }) |
143 | 144 | } |
144 | 145 | ) |
| 146 | + |
| 147 | + describe('a database that is temporarily unavailable', () => { |
| 148 | + const SQL = 'select "secret_column" from "workflow_execution_logs" where "id" = $1' |
| 149 | + |
| 150 | + function failedQuery(code: string, message: string): DrizzleQueryError { |
| 151 | + return new DrizzleQueryError(SQL, ['id-1'], Object.assign(new Error(message), { code })) |
| 152 | + } |
| 153 | + |
| 154 | + function familyRoute(error: unknown) { |
| 155 | + return withRouteHandler( |
| 156 | + async () => { |
| 157 | + throw error |
| 158 | + }, |
| 159 | + { |
| 160 | + typedErrorResponse: ({ error: typed, status }) => |
| 161 | + NextResponse.json({ family: 'typed', error: typed.message }, { status }), |
| 162 | + unhandledErrorResponse: () => NextResponse.json({ family: 'generic' }, { status: 500 }), |
| 163 | + } |
| 164 | + ) |
| 165 | + } |
| 166 | + |
| 167 | + it.each([ |
| 168 | + [ |
| 169 | + 'a replica recovery conflict', |
| 170 | + failedQuery('40001', 'canceling statement due to conflict with recovery'), |
| 171 | + ], |
| 172 | + ['a statement timeout', failedQuery('57014', 'canceling statement due to statement timeout')], |
| 173 | + [ |
| 174 | + 'a connection lost under the query', |
| 175 | + failedQuery('CONNECTION_CLOSED', 'write CONNECTION_CLOSED'), |
| 176 | + ], |
| 177 | + [ |
| 178 | + 'a server restarting', |
| 179 | + failedQuery('57P01', 'terminating connection due to administrator command'), |
| 180 | + ], |
| 181 | + ])('answers a read failing on %s with a retryable 503', async (_, error) => { |
| 182 | + const response = await familyRoute(error)( |
| 183 | + new NextRequest('http://localhost/api/test'), |
| 184 | + undefined |
| 185 | + ) |
| 186 | + |
| 187 | + expect(response.status).toBe(503) |
| 188 | + expect(Number(response.headers.get('Retry-After'))).toBeGreaterThan(0) |
| 189 | + const body = await response.json() |
| 190 | + expect(body.family).toBe('typed') |
| 191 | + expect(JSON.stringify(body)).not.toContain('secret_column') |
| 192 | + }) |
| 193 | + |
| 194 | + it('answers a HEAD the same way as its GET', async () => { |
| 195 | + const response = await familyRoute( |
| 196 | + failedQuery('40001', 'canceling statement due to conflict with recovery') |
| 197 | + )(new NextRequest('http://localhost/api/test', { method: 'HEAD' }), undefined) |
| 198 | + |
| 199 | + expect(response.status).toBe(503) |
| 200 | + }) |
| 201 | + |
| 202 | + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])( |
| 203 | + 'leaves a %s unhandled, since an earlier statement may already have committed', |
| 204 | + async (method) => { |
| 205 | + const response = await familyRoute( |
| 206 | + failedQuery('40001', 'canceling statement due to conflict with recovery') |
| 207 | + )(new NextRequest('http://localhost/api/test', { method }), undefined) |
| 208 | + |
| 209 | + expect(response.status).toBe(500) |
| 210 | + expect(response.headers.get('Retry-After')).toBeNull() |
| 211 | + } |
| 212 | + ) |
| 213 | + |
| 214 | + it.each([ |
| 215 | + ['a user-requested cancel', failedQuery('57014', 'canceling statement due to user request')], |
| 216 | + ['a permanent query error', failedQuery('42601', 'syntax error at or near ")"')], |
| 217 | + [ |
| 218 | + 'a socket reset outside any query', |
| 219 | + Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }), |
| 220 | + ], |
| 221 | + ])('leaves %s on a read unhandled', async (_, error) => { |
| 222 | + const response = await familyRoute(error)( |
| 223 | + new NextRequest('http://localhost/api/test'), |
| 224 | + undefined |
| 225 | + ) |
| 226 | + |
| 227 | + expect(response.status).toBe(500) |
| 228 | + await expect(response.json()).resolves.toEqual({ family: 'generic' }) |
| 229 | + }) |
| 230 | + }) |
145 | 231 | }) |
0 commit comments