Skip to content

Commit 31c7e0d

Browse files
authored
Merge pull request #42 from MatrixAI/feature-middlware-composition
feat: incorporate timeoutMiddleware to allow for server to utilise client's caller timeout
2 parents 8434c70 + 8d38826 commit 31c7e0d

12 files changed

+565
-88
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,45 @@ main();
899899

900900
```
901901
![img.png](images/unaryTest.png)
902+
903+
## Specifications
904+
905+
### Throwing Timeouts
906+
907+
By default, a timeout will not cause an RPC call to automatically throw, this must be manually done by the handler when it receives the abort signal from `ctx.signal`. An example of this is like so:
908+
909+
```ts
910+
class TestMethod extends UnaryHandler {
911+
public handle = async (
912+
input: JSONValue,
913+
cancel: (reason?: any) => void,
914+
meta: Record<string, JSONValue> | undefined,
915+
ctx: ContextTimed,
916+
): Promise<JSONValue> => {
917+
const abortProm = utils.promise<never>();
918+
ctx.signal.addEventListener('abort', () => {
919+
resolveCtxP(ctx);
920+
abortProm.resolveP(ctx.signal.reason);
921+
});
922+
throw await abortProm.p;
923+
};
924+
}
925+
```
926+
927+
### Timeout Middleware
928+
929+
The `timeoutMiddleware` sets an RPCServer's timeout based on the lowest timeout between the Client and the Server. This is so that handlers can eagerly time out and stop processing as soon as it is known that the client has timed out.
930+
931+
This case can be seen in the first diagram, where the server is able to stop the processing of the handler, and close the associated stream of the RPC call based on the shorter timeout sent by the client:
932+
933+
![RPCServer sets timeout based on RPCClient](images/timeoutMiddlewareClientTimeout.svg)
934+
935+
Where the `RPCClient` sends a timeout that is longer than that set on the `RPCServer`, it will be rejected. This is as the timeout of the client should never be expected to exceed that of the server, so that the server's timeout is an absolute limit.
936+
937+
![RPCServer rejects longer timeout sent by RPCClient](images/timeoutMiddlewareServerTimeout.svg)
938+
939+
The `timeoutMiddleware` is enabled by default, and uses the `.metadata.timeout` property on a JSON-RPC request object for the client to send it's timeout.
940+
902941
## Development
903942

904943
Run `nix-shell`, and once you're inside, you can use:

images/timeoutMiddlewareClientTimeout.svg

Lines changed: 17 additions & 0 deletions
Loading

images/timeoutMiddlewareServerTimeout.svg

Lines changed: 17 additions & 0 deletions
Loading

src/RPCServer.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ class RPCServer {
287287
// Input generator derived from the forward stream
288288
const inputGen = async function* (): AsyncIterable<I> {
289289
for await (const data of forwardStream) {
290-
ctx.timer.refresh();
290+
if (ctx.timer.status !== 'settled') {
291+
ctx.timer.refresh();
292+
}
291293
yield data.params as I;
292294
}
293295
};
@@ -296,7 +298,9 @@ class RPCServer {
296298
timer: ctx.timer,
297299
});
298300
for await (const response of handlerG) {
299-
ctx.timer.refresh();
301+
if (ctx.timer.status !== 'settled') {
302+
ctx.timer.refresh();
303+
}
300304
const responseMessage: JSONRPCResponseResult = {
301305
jsonrpc: '2.0',
302306
result: response,
@@ -570,13 +574,16 @@ class RPCServer {
570574
}
571575
// Setting up Timeout logic
572576
const timeout = this.defaultTimeoutMap.get(method);
573-
if (timeout != null && timeout < this.handlerTimeoutTime) {
574-
// Reset timeout with new delay if it is less than the default
575-
timer.reset(timeout);
576-
} else {
577-
// Otherwise refresh
578-
timer.refresh();
577+
if (timer.status !== 'settled') {
578+
if (timeout != null && timeout < this.handlerTimeoutTime) {
579+
// Reset timeout with new delay if it is less than the default
580+
timer.reset(timeout);
581+
} else {
582+
// Otherwise refresh
583+
timer.refresh();
584+
}
579585
}
586+
580587
this.logger.info(`Handling stream with method (${method})`);
581588
let handlerResult: [JSONValue | undefined, ReadableStream<Uint8Array>];
582589
const headerWriter = rpcStream.writable.getWriter();

src/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ class ErrorRPCStreamEnded<T> extends ErrorRPCProtocol<T> {
148148
class ErrorRPCTimedOut<T> extends ErrorRPCProtocol<T> {
149149
static description = 'RPC handler has timed out';
150150
code = JSONRPCErrorCode.RPCTimedOut;
151+
public toJSON(): JSONRPCError {
152+
const json = super.toJSON();
153+
if (typeof json === 'object' && !Array.isArray(json)) {
154+
(json as POJO).type = this.constructor.name;
155+
}
156+
return json;
157+
}
151158
}
152159

153160
class ErrorUtilsUndefinedBehaviour<T> extends ErrorRPCProtocol<T> {

src/middleware.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type {
44
JSONRPCResponse,
55
JSONRPCResponseResult,
66
MiddlewareFactory,
7+
JSONValue,
8+
JSONRPCRequestMetadata,
9+
JSONRPCResponseMetadata,
710
} from './types';
11+
import type { ContextTimed } from '@matrixai/contexts';
812
import { TransformStream } from 'stream/web';
913
import { JSONParser } from '@streamparser/json';
1014
import * as utils from './utils';
@@ -75,6 +79,80 @@ function jsonMessageToBinaryStream(): TransformStream<
7579
});
7680
}
7781

82+
function timeoutMiddlewareServer(
83+
ctx: ContextTimed,
84+
_cancel: (reason?: any) => void,
85+
_meta: Record<string, JSONValue> | undefined,
86+
) {
87+
const currentTimeout = ctx.timer.delay;
88+
// Flags for tracking if the first message has been processed
89+
let forwardFirst = true;
90+
return {
91+
forward: new TransformStream<
92+
JSONRPCRequest<JSONRPCRequestMetadata>,
93+
JSONRPCRequest<JSONRPCRequestMetadata>
94+
>({
95+
transform: (chunk, controller) => {
96+
controller.enqueue(chunk);
97+
if (forwardFirst) {
98+
forwardFirst = false;
99+
let clientTimeout = chunk.metadata?.timeout;
100+
if (clientTimeout === undefined) return;
101+
if (clientTimeout === null) clientTimeout = Infinity;
102+
if (clientTimeout < currentTimeout) ctx.timer.reset(clientTimeout);
103+
}
104+
},
105+
}),
106+
reverse: new TransformStream<
107+
JSONRPCResponse<JSONRPCResponseMetadata>,
108+
JSONRPCResponse<JSONRPCResponseMetadata>
109+
>({
110+
transform: (chunk, controller) => {
111+
// Passthrough chunk, no need for server to send ctx.timeout
112+
controller.enqueue(chunk);
113+
},
114+
}),
115+
};
116+
}
117+
118+
/**
119+
* This adds its own timeout to the forward metadata and updates it's timeout
120+
* based on the reverse metadata.
121+
* @param ctx
122+
* @param _cancel
123+
* @param _meta
124+
*/
125+
function timeoutMiddlewareClient(
126+
ctx: ContextTimed,
127+
_cancel: (reason?: any) => void,
128+
_meta: Record<string, JSONValue> | undefined,
129+
) {
130+
const currentTimeout = ctx.timer.delay;
131+
// Flags for tracking if the first message has been processed
132+
let forwardFirst = true;
133+
return {
134+
forward: new TransformStream<JSONRPCRequest, JSONRPCRequest>({
135+
transform: (chunk, controller) => {
136+
if (forwardFirst) {
137+
forwardFirst = false;
138+
if (chunk == null) chunk = { jsonrpc: '2.0', method: '' };
139+
if (chunk.metadata == null) chunk.metadata = {};
140+
(chunk.metadata as any).timeout = currentTimeout;
141+
}
142+
controller.enqueue(chunk);
143+
},
144+
}),
145+
reverse: new TransformStream<
146+
JSONRPCResponse<JSONRPCResponseMetadata>,
147+
JSONRPCResponse<JSONRPCResponseMetadata>
148+
>({
149+
transform: (chunk, controller) => {
150+
controller.enqueue(chunk); // Passthrough chunk, no need for client to set ctx.timeout
151+
},
152+
}),
153+
};
154+
}
155+
78156
/**
79157
* This function is a factory for creating a pass-through streamPair. It is used
80158
* as the default middleware for the middleware wrappers.
@@ -116,12 +194,14 @@ function defaultServerMiddlewareWrapper(
116194
>();
117195

118196
const middleMiddleware = middlewareFactory(ctx, cancel, meta);
197+
const timeoutMiddleware = timeoutMiddlewareServer(ctx, cancel, meta);
119198

120-
const forwardReadable = inputTransformStream.readable.pipeThrough(
121-
middleMiddleware.forward,
122-
); // Usual middleware here
199+
const forwardReadable = inputTransformStream.readable
200+
.pipeThrough(timeoutMiddleware.forward) // Timeout middleware here
201+
.pipeThrough(middleMiddleware.forward); // Usual middleware here
123202
const reverseReadable = outputTransformStream.readable
124203
.pipeThrough(middleMiddleware.reverse) // Usual middleware here
204+
.pipeThrough(timeoutMiddleware.reverse) // Timeout middleware here
125205
.pipeThrough(jsonMessageToBinaryStream());
126206

127207
return {
@@ -172,13 +252,15 @@ const defaultClientMiddlewareWrapper = (
172252
JSONRPCRequest
173253
>();
174254

255+
const timeoutMiddleware = timeoutMiddlewareClient(ctx, cancel, meta);
175256
const middleMiddleware = middleware(ctx, cancel, meta);
176257
const forwardReadable = inputTransformStream.readable
258+
.pipeThrough(timeoutMiddleware.forward)
177259
.pipeThrough(middleMiddleware.forward) // Usual middleware here
178260
.pipeThrough(jsonMessageToBinaryStream());
179-
const reverseReadable = outputTransformStream.readable.pipeThrough(
180-
middleMiddleware.reverse,
181-
); // Usual middleware here
261+
const reverseReadable = outputTransformStream.readable
262+
.pipeThrough(middleMiddleware.reverse)
263+
.pipeThrough(timeoutMiddleware.reverse); // Usual middleware here
182264

183265
return {
184266
forward: {
@@ -196,6 +278,8 @@ const defaultClientMiddlewareWrapper = (
196278
export {
197279
binaryToJsonMessageStream,
198280
jsonMessageToBinaryStream,
281+
timeoutMiddlewareClient,
282+
timeoutMiddlewareServer,
199283
defaultMiddleware,
200284
defaultServerMiddlewareWrapper,
201285
defaultClientMiddlewareWrapper,

src/types.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ type JSONRPCRequestMessage<T extends JSONValue = JSONValue> = {
3838
* SHOULD NOT contain fractional parts [2]
3939
*/
4040
id: string | number | null;
41-
};
41+
} & JSONRPCRequestMetadata;
4242

4343
/**
4444
* This is the JSON RPC notification object. this is used for a request that
@@ -60,7 +60,7 @@ type JSONRPCRequestNotification<T extends JSONValue = JSONValue> = {
6060
* This member MAY be omitted.
6161
*/
6262
params?: T;
63-
};
63+
} & JSONRPCRequestMetadata;
6464

6565
/**
6666
* This is the JSON RPC response result object. It contains the response data for a
@@ -84,7 +84,7 @@ type JSONRPCResponseResult<T extends JSONValue = JSONValue> = {
8484
* it MUST be Null.
8585
*/
8686
id: string | number | null;
87-
};
87+
} & JSONRPCResponseMetadata;
8888

8989
/**
9090
* This is the JSON RPC response Error object. It contains any errors that have
@@ -110,6 +110,34 @@ type JSONRPCResponseError = {
110110
id: string | number | null;
111111
};
112112

113+
/**
114+
* Used when an empty object is needed.
115+
* Defined here with a linter override to avoid a false positive.
116+
*/
117+
// eslint-disable-next-line
118+
type ObjectEmpty = {};
119+
120+
// Prevent overwriting the metadata type with `Omit<>`
121+
type JSONRPCRequestMetadata<T extends Record<string, JSONValue> = ObjectEmpty> =
122+
{
123+
metadata?: {
124+
[Key: string]: JSONValue;
125+
} & Partial<{
126+
timeout: number | null;
127+
}>;
128+
} & Omit<T, 'metadata'>;
129+
130+
// Prevent overwriting the metadata type with `Omit<>`
131+
type JSONRPCResponseMetadata<
132+
T extends Record<string, JSONValue> = ObjectEmpty,
133+
> = {
134+
metadata?: {
135+
[Key: string]: JSONValue;
136+
} & Partial<{
137+
timeout: number | null;
138+
}>;
139+
} & Omit<T, 'metadata'>;
140+
113141
/**
114142
* This is a JSON RPC error object, it encodes the error data for the JSONRPCResponseError object.
115143
*/
@@ -357,6 +385,8 @@ export type {
357385
JSONRPCRequestNotification,
358386
JSONRPCResponseResult,
359387
JSONRPCResponseError,
388+
JSONRPCRequestMetadata,
389+
JSONRPCResponseMetadata,
360390
JSONRPCError,
361391
JSONRPCRequest,
362392
JSONRPCResponse,

src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ const standardErrors: {
278278
URIError,
279279
AggregateError,
280280
AbstractError,
281+
ErrorRPCTimedOut: errors.ErrorRPCTimedOut,
281282
};
282283

283284
/**
@@ -342,6 +343,7 @@ function toError(
342343
let e: Error;
343344
switch (eClass) {
344345
case AbstractError:
346+
case errors.ErrorRPCTimedOut:
345347
e = eClass.fromJSON(errorData);
346348
break;
347349
case AggregateError:

0 commit comments

Comments
 (0)