Skip to content

Commit ec6df9f

Browse files
authored
Add custom error handler to honour content-encoding (#649)
1 parent 15e2466 commit ec6df9f

4 files changed

Lines changed: 361 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-core': patch
3+
---
4+
5+
Add a custom Fastify error handler so uncaught errors honour the negotiated `Content-Encoding` (gzip/zstd) instead of returning a header that doesn't match the body.

packages/service-core/src/routes/configure-fastify.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type fastify from 'fastify';
22

3-
import { registerFastifyNotFoundHandler, registerFastifyRoutes } from './route-register.js';
3+
import {
4+
registerFastifyErrorHandler,
5+
registerFastifyNotFoundHandler,
6+
registerFastifyRoutes
7+
} from './route-register.js';
48

59
import * as system from '../system/system-index.js';
610

@@ -66,6 +70,9 @@ export function configureFastifyServer(server: fastify.FastifyInstance, options:
6670
};
6771
};
6872

73+
// Set on the outer server so both child scopes inherit.
74+
registerFastifyErrorHandler(server);
75+
6976
/**
7077
* Fastify creates an encapsulated context for each `.register` call.
7178
* Creating a separate context here to separate the concurrency limits for Admin APIs

packages/service-core/src/routes/route-register.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type fastify from 'fastify';
2+
import * as zlib from 'node:zlib';
23
import * as uuid from 'uuid';
34

4-
import { errors, HTTPMethod, logger, RouteNotFound, router, ServiceError } from '@powersync/lib-services-framework';
5+
import { errors, HTTPMethod, logger, RouteNotFound, router } from '@powersync/lib-services-framework';
56
import { FastifyReply } from 'fastify';
67
import { Context, ContextProvider, RequestEndpoint, RequestEndpointHandlerPayload } from './router.js';
78

@@ -105,18 +106,86 @@ export function registerFastifyNotFoundHandler(app: fastify.FastifyInstance) {
105106
});
106107
}
107108

108-
function serviceErrorToResponse(error: ServiceError): router.RouterResponse {
109+
/** Registers a custom error handler that emits service-error JSON honouring any negotiated `Content-Encoding`. */
110+
export function registerFastifyErrorHandler(app: fastify.FastifyInstance) {
111+
app.setErrorHandler<Error>(async (error, _request, reply) => {
112+
if (reply.raw.headersSent) {
113+
// Headers already flushed - reply.code/header would throw ERR_HTTP_HEADERS_SENT.
114+
reply.raw.destroy(error);
115+
return;
116+
}
117+
118+
const { status, data } = fastifyErrorToResponse(error);
119+
if (status >= 500) {
120+
logger.error('Request failed', error);
121+
} else {
122+
logger.warn(`Request failed: ${error.message}`, {
123+
status,
124+
code: data.error.code
125+
});
126+
}
127+
128+
const json = JSON.stringify(data);
129+
130+
// Fastify's default handler leaves `content-encoding` intact when it rewrites the body.
131+
const encoding = reply.getHeader('content-encoding');
132+
let body: string | Buffer = json;
133+
if (encoding === 'gzip') {
134+
body = zlib.gzipSync(json);
135+
} else if (encoding === 'zstd') {
136+
body = zlib.zstdCompressSync(json);
137+
} else {
138+
reply.removeHeader('content-encoding');
139+
}
140+
141+
reply
142+
.code(status)
143+
.header('content-type', 'application/json; charset=utf-8')
144+
.header('content-length', Buffer.byteLength(body))
145+
.send(body);
146+
});
147+
}
148+
149+
type ErrorResponseData = { error: errors.ErrorData };
150+
151+
function serviceErrorToResponse(serviceError: errors.ServiceError): router.RouterResponse<ErrorResponseData> {
109152
return new router.RouterResponse({
110-
status: error.errorData.status || 500,
153+
status: serviceError.errorData.status || 500,
111154
headers: {
112155
'Content-Type': 'application/json'
113156
},
114157
data: {
115-
error: error.errorData
158+
error: serviceError.errorData
116159
}
117160
});
118161
}
119162

163+
function fastifyErrorToResponse(error: any): router.RouterResponse<ErrorResponseData> {
164+
// Preserve 4xx status from Fastify built-ins (validation, invalid JSON body, etc.) instead of collapsing to 500.
165+
if (
166+
typeof error?.statusCode === 'number' &&
167+
error.statusCode >= 400 &&
168+
error.statusCode < 500 &&
169+
typeof error.code === 'string'
170+
) {
171+
return new router.RouterResponse({
172+
status: error.statusCode,
173+
headers: {
174+
'Content-Type': 'application/json'
175+
},
176+
data: {
177+
error: {
178+
name: error.name,
179+
code: error.code,
180+
status: error.statusCode,
181+
description: error.message
182+
}
183+
}
184+
});
185+
}
186+
return serviceErrorToResponse(errors.asServiceError(error));
187+
}
188+
120189
async function respond(reply: FastifyReply, response: router.RouterResponse) {
121190
Object.keys(response.headers).forEach((key) => {
122191
reply.header(key, response.headers[key]);

0 commit comments

Comments
 (0)