Repository: solid/community-server
Pull request #555 (2021-01-26 22:10:31)

Execute handlers in parallel when possible


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17eb29b41..74c9335af 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: '14.x'
       - run: npm ci --ignore-scripts
       - run: npm run lint
 
@@ -26,8 +26,12 @@ jobs:
     strategy:
       matrix:
         node-version:
-          - 12.x
-          - 14.x
+          - '12.17'
+          - '12.x'
+          - '14.0'
+          - '14.x'
+          - '15.0'
+          - '15.x'
     env:
       TEST_DOCKER: true
     services:
@@ -70,8 +74,9 @@ jobs:
     strategy:
       matrix:
         node-version:
-          - 12.x
-          - 14.x
+          - '12.x'
+          - '14.x'
+          - '15.x'
     steps:
       - name: Use Node.js ${{ matrix.node-version }}
         uses: actions/setup-node@v2
@@ -108,7 +113,7 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: '14.x'
       - run: npm ci --ignore-scripts
       - run: npm run docs
       - name: Save docs
diff --git a/config/presets/ldp/response-writer.json b/config/presets/ldp/response-writer.json
index 6582754d2..17ba44c49 100644
--- a/config/presets/ldp/response-writer.json
+++ b/config/presets/ldp/response-writer.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:MetadataSerializer",
-      "@type": "SequenceHandler",
-      "SequenceHandler:_handlers": [
+      "@type": "ParallelHandler",
+      "ParallelHandler:_handlers": [
         {
           "@type": "ConstantMetadataWriter",
           "ConstantMetadataWriter:_headers": [
diff --git a/config/presets/middleware.json b/config/presets/middleware.json
index 6aa2c9be3..b0bbad02e 100644
--- a/config/presets/middleware.json
+++ b/config/presets/middleware.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:Middleware",
-      "@type": "SequenceHandler",
-      "SequenceHandler:_handlers": [
+      "@type": "ParallelHandler",
+      "ParallelHandler:_handlers": [
         {
           "@type": "CorsHandler",
           "CorsHandler:_options_methods": [
diff --git a/package.json b/package.json
index 8b9a499bd..b9469ceb0 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
   ],
   "license": "MIT",
   "engines": {
-    "node": ">=12.17 <15"
+    "node": ">=12.17"
   },
   "lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server",
   "lsd:components": "dist/components/components.jsonld",
diff --git a/src/authentication/CredentialsExtractor.ts b/src/authentication/CredentialsExtractor.ts
index 12fafa265..1d7294b2a 100644
--- a/src/authentication/CredentialsExtractor.ts
+++ b/src/authentication/CredentialsExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../server/HttpRequest';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { Credentials } from './Credentials';
 
 /**
diff --git a/src/authorization/Authorizer.ts b/src/authorization/Authorizer.ts
index 02f603dcd..8e1dd8e05 100644
--- a/src/authorization/Authorizer.ts
+++ b/src/authorization/Authorizer.ts
@@ -1,7 +1,7 @@
 import type { Credentials } from '../authentication/Credentials';
 import type { PermissionSet } from '../ldp/permissions/PermissionSet';
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 
 /**
  * Verifies if the given credentials have access to the given permissions on the given resource.
diff --git a/src/index.ts b/src/index.ts
index b86bbaef1..2c18b7a37 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -187,6 +187,13 @@ export * from './util/errors/SystemError';
 export * from './util/errors/UnauthorizedHttpError';
 export * from './util/errors/UnsupportedMediaTypeHttpError';
 
+// Util/Handlers
+export * from './util/handlers/AsyncHandler';
+export * from './util/handlers/ParallelHandler';
+export * from './util/handlers/SequenceHandler';
+export * from './util/handlers/UnsupportedAsyncHandler';
+export * from './util/handlers/WaterfallHandler';
+
 // Util/Identifiers
 export * from './util/identifiers/IdentifierStrategy';
 export * from './util/identifiers/SingleRootIdentifierStrategy';
@@ -198,16 +205,12 @@ export * from './util/locking/SingleThreadedResourceLocker';
 export * from './util/locking/WrappedExpiringResourceLocker';
 
 // Util
-export * from './util/AsyncHandler';
 export * from './util/ContentTypes';
 export * from './util/GuardedStream';
 export * from './util/HeaderUtil';
 export * from './util/PathUtil';
 export * from './util/QuadUtil';
 export * from './util/RecordObject';
-export * from './util/SequenceHandler';
 export * from './util/StreamUtil';
 export * from './util/TermUtil';
-export * from './util/UnsupportedAsyncHandler';
 export * from './util/Vocabularies';
-export * from './util/WaterfallHandler';
diff --git a/src/init/Initializer.ts b/src/init/Initializer.ts
index 9348beb2e..502475eba 100644
--- a/src/init/Initializer.ts
+++ b/src/init/Initializer.ts
@@ -1,3 +1,3 @@
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 
 export abstract class Initializer extends AsyncHandler {}
diff --git a/src/ldp/http/BodyParser.ts b/src/ldp/http/BodyParser.ts
index 0d0a774c4..0e36aff2b 100644
--- a/src/ldp/http/BodyParser.ts
+++ b/src/ldp/http/BodyParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Representation } from '../representation/Representation';
 import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
 
diff --git a/src/ldp/http/PreferenceParser.ts b/src/ldp/http/PreferenceParser.ts
index d8e9c912a..f0000881a 100644
--- a/src/ldp/http/PreferenceParser.ts
+++ b/src/ldp/http/PreferenceParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
 
 /**
diff --git a/src/ldp/http/RequestParser.ts b/src/ldp/http/RequestParser.ts
index d0dd1dfe6..281793dc4 100644
--- a/src/ldp/http/RequestParser.ts
+++ b/src/ldp/http/RequestParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Operation } from '../operations/Operation';
 
 /**
diff --git a/src/ldp/http/ResponseWriter.ts b/src/ldp/http/ResponseWriter.ts
index ba7d722ab..926c320c5 100644
--- a/src/ldp/http/ResponseWriter.ts
+++ b/src/ldp/http/ResponseWriter.ts
@@ -1,5 +1,5 @@
 import type { HttpResponse } from '../../server/HttpResponse';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResponseDescription } from './response/ResponseDescription';
 
 /**
diff --git a/src/ldp/http/TargetExtractor.ts b/src/ldp/http/TargetExtractor.ts
index d6d74b368..1ce24fe0d 100644
--- a/src/ldp/http/TargetExtractor.ts
+++ b/src/ldp/http/TargetExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
 
 /**
diff --git a/src/ldp/http/metadata/MetadataExtractor.ts b/src/ldp/http/metadata/MetadataExtractor.ts
index 2af766480..0c5f94f33 100644
--- a/src/ldp/http/metadata/MetadataExtractor.ts
+++ b/src/ldp/http/metadata/MetadataExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../../server/HttpRequest';
-import { AsyncHandler } from '../../../util/AsyncHandler';
+import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
 import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../representation/ResourceIdentifier';
 
diff --git a/src/ldp/http/metadata/MetadataWriter.ts b/src/ldp/http/metadata/MetadataWriter.ts
index 46dd8aa3f..8b91f7830 100644
--- a/src/ldp/http/metadata/MetadataWriter.ts
+++ b/src/ldp/http/metadata/MetadataWriter.ts
@@ -1,5 +1,5 @@
 import type { HttpResponse } from '../../../server/HttpResponse';
-import { AsyncHandler } from '../../../util/AsyncHandler';
+import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
 import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
 
 /**
diff --git a/src/ldp/operations/OperationHandler.ts b/src/ldp/operations/OperationHandler.ts
index 014336d70..724cd3d3e 100644
--- a/src/ldp/operations/OperationHandler.ts
+++ b/src/ldp/operations/OperationHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResponseDescription } from '../http/response/ResponseDescription';
 import type { Operation } from './Operation';
 
diff --git a/src/ldp/permissions/PermissionsExtractor.ts b/src/ldp/permissions/PermissionsExtractor.ts
index ea562b107..168ad919d 100644
--- a/src/ldp/permissions/PermissionsExtractor.ts
+++ b/src/ldp/permissions/PermissionsExtractor.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Operation } from '../operations/Operation';
 import type { PermissionSet } from './PermissionSet';
 
diff --git a/src/pods/agent/AgentParser.ts b/src/pods/agent/AgentParser.ts
index f1b3c64d3..4098fa6cf 100644
--- a/src/pods/agent/AgentParser.ts
+++ b/src/pods/agent/AgentParser.ts
@@ -1,5 +1,5 @@
 import type { Representation } from '../../ldp/representation/Representation';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Agent } from './Agent';
 
 /**
diff --git a/src/server/HttpHandler.ts b/src/server/HttpHandler.ts
index 7505b232a..a48314ae9 100644
--- a/src/server/HttpHandler.ts
+++ b/src/server/HttpHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { HttpRequest } from './HttpRequest';
 import type { HttpResponse } from './HttpResponse';
 
diff --git a/src/server/WebSocketHandler.ts b/src/server/WebSocketHandler.ts
index 32ab66cc8..ec7c40f5a 100644
--- a/src/server/WebSocketHandler.ts
+++ b/src/server/WebSocketHandler.ts
@@ -1,5 +1,5 @@
 import type WebSocket from 'ws';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { HttpRequest } from './HttpRequest';
 
 /**
diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts
index 4a00d2d27..ffcdcfd9f 100644
--- a/src/storage/conversion/IfNeededConverter.ts
+++ b/src/storage/conversion/IfNeededConverter.ts
@@ -1,7 +1,7 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import { getLoggerFor } from '../../logging/LogUtil';
 import { InternalServerError } from '../../util/errors/InternalServerError';
-import { UnsupportedAsyncHandler } from '../../util/UnsupportedAsyncHandler';
+import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler';
 import { hasMatchingMediaTypes } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
diff --git a/src/storage/conversion/RepresentationConverter.ts b/src/storage/conversion/RepresentationConverter.ts
index 9197ce173..a9bc02d94 100644
--- a/src/storage/conversion/RepresentationConverter.ts
+++ b/src/storage/conversion/RepresentationConverter.ts
@@ -1,7 +1,7 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 
 export interface RepresentationConverterArgs {
   /**
diff --git a/src/storage/patch/PatchHandler.ts b/src/storage/patch/PatchHandler.ts
index e03ec21f1..0a44d8be3 100644
--- a/src/storage/patch/PatchHandler.ts
+++ b/src/storage/patch/PatchHandler.ts
@@ -1,5 +1,5 @@
 import type { Patch } from '../../ldp/http/Patch';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 
 export abstract class PatchHandler extends AsyncHandler<{identifier: ResourceIdentifier; patch: Patch}> {}
diff --git a/src/storage/routing/RouterRule.ts b/src/storage/routing/RouterRule.ts
index 83cadd8e6..409cd610f 100644
--- a/src/storage/routing/RouterRule.ts
+++ b/src/storage/routing/RouterRule.ts
@@ -1,6 +1,6 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResourceStore } from '../ResourceStore';
 
 /**
diff --git a/src/util/AsyncHandler.ts b/src/util/handlers/AsyncHandler.ts
similarity index 100%
rename from src/util/AsyncHandler.ts
rename to src/util/handlers/AsyncHandler.ts
diff --git a/src/util/handlers/ParallelHandler.ts b/src/util/handlers/ParallelHandler.ts
new file mode 100644
index 000000000..3d78fb88b
--- /dev/null
+++ b/src/util/handlers/ParallelHandler.ts
@@ -0,0 +1,23 @@
+import { AsyncHandler } from './AsyncHandler';
+
+/**
+ * A composite handler that executes handlers in parallel.
+ */
+export class ParallelHandler<TIn = void, TOut = void> extends AsyncHandler<TIn, TOut[]> {
+  private readonly handlers: AsyncHandler<TIn, TOut>[];
+
+  public constructor(handlers: AsyncHandler<TIn, TOut>[]) {
+    super();
+    this.handlers = [ ...handlers ];
+  }
+
+  public async canHandle(input: TIn): Promise<void> {
+    // eslint-disable-next-line @typescript-eslint/promise-function-async
+    await Promise.all(this.handlers.map((handler): Promise<void> => handler.canHandle(input)));
+  }
+
+  public async handle(input: TIn): Promise<TOut[]> {
+    // eslint-disable-next-line @typescript-eslint/promise-function-async
+    return Promise.all(this.handlers.map((handler): Promise<TOut> => handler.handle(input)));
+  }
+}
diff --git a/src/util/SequenceHandler.ts b/src/util/handlers/SequenceHandler.ts
similarity index 100%
rename from src/util/SequenceHandler.ts
rename to src/util/handlers/SequenceHandler.ts
diff --git a/src/util/UnsupportedAsyncHandler.ts b/src/util/handlers/UnsupportedAsyncHandler.ts
similarity index 87%
rename from src/util/UnsupportedAsyncHandler.ts
rename to src/util/handlers/UnsupportedAsyncHandler.ts
index 936d433e2..7099ea5d8 100644
--- a/src/util/UnsupportedAsyncHandler.ts
+++ b/src/util/handlers/UnsupportedAsyncHandler.ts
@@ -1,5 +1,5 @@
+import { NotImplementedHttpError } from '../errors/NotImplementedHttpError';
 import { AsyncHandler } from './AsyncHandler';
-import { NotImplementedHttpError } from './errors/NotImplementedHttpError';
 
 /**
  * Handler that does not support any input and will always throw an error.
diff --git a/src/util/WaterfallHandler.ts b/src/util/handlers/WaterfallHandler.ts
similarity index 92%
rename from src/util/WaterfallHandler.ts
rename to src/util/handlers/WaterfallHandler.ts
index 4ab45b10e..50a5dc5d0 100644
--- a/src/util/WaterfallHandler.ts
+++ b/src/util/handlers/WaterfallHandler.ts
@@ -1,9 +1,9 @@
-import { getLoggerFor } from '../logging/LogUtil';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { BadRequestHttpError } from '../errors/BadRequestHttpError';
+import { isNativeError } from '../errors/ErrorUtil';
+import { HttpError } from '../errors/HttpError';
+import { InternalServerError } from '../errors/InternalServerError';
 import type { AsyncHandler } from './AsyncHandler';
-import { BadRequestHttpError } from './errors/BadRequestHttpError';
-import { isNativeError } from './errors/ErrorUtil';
-import { HttpError } from './errors/HttpError';
-import { InternalServerError } from './errors/InternalServerError';
 
 /**
  * A composite handler that tries multiple handlers one by one


Test patch:
diff --git a/test/integration/LockingResourceStore.test.ts b/test/integration/LockingResourceStore.test.ts
index 48220c828..05f04265f 100644
--- a/test/integration/LockingResourceStore.test.ts
+++ b/test/integration/LockingResourceStore.test.ts
@@ -1,4 +1,3 @@
-import type { Readable } from 'stream';
 import { RootContainerInitializer } from '../../src/init/RootContainerInitializer';
 import { BasicRepresentation } from '../../src/ldp/representation/BasicRepresentation';
 import type { Representation } from '../../src/ldp/representation/Representation';
@@ -18,12 +17,6 @@ import { BASE } from './Config';
 
 jest.useFakeTimers();
 
-async function readOnce(stream: Readable): Promise<any> {
-  return await new Promise((resolve): void => {
-    stream.once('data', resolve);
-  });
-}
-
 describe('A LockingResourceStore', (): void => {
   let path: string;
   let store: LockingResourceStore;
@@ -82,11 +75,13 @@ describe('A LockingResourceStore', (): void => {
 
     // Wait 750ms and read
     jest.advanceTimersByTime(750);
-    await expect(readOnce(representation.data)).resolves.toBe(1);
+    expect(representation.data.destroyed).toBe(false);
+    representation.data.read();
 
     // Wait 750ms and read
     jest.advanceTimersByTime(750);
-    await expect(readOnce(representation.data)).resolves.toBe(2);
+    expect(representation.data.destroyed).toBe(false);
+    representation.data.read();
 
     // Wait 1000ms and watch the stream be destroyed
     jest.advanceTimersByTime(1000);
diff --git a/test/unit/util/AsyncHandler.test.ts b/test/unit/util/handlers/AsyncHandler.test.ts
similarity index 91%
rename from test/unit/util/AsyncHandler.test.ts
rename to test/unit/util/handlers/AsyncHandler.test.ts
index e22770d3b..30fe80a3f 100644
--- a/test/unit/util/AsyncHandler.test.ts
+++ b/test/unit/util/handlers/AsyncHandler.test.ts
@@ -1,5 +1,5 @@
-import { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
+import { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler';
 
 describe('An AsyncHandler', (): void => {
   it('supports any input by default.', async(): Promise<void> => {
diff --git a/test/unit/util/handlers/ParallelHandler.test.ts b/test/unit/util/handlers/ParallelHandler.test.ts
new file mode 100644
index 000000000..3d8792ba7
--- /dev/null
+++ b/test/unit/util/handlers/ParallelHandler.test.ts
@@ -0,0 +1,100 @@
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { ParallelHandler } from '../../../../src/util/handlers/ParallelHandler';
+
+describe('A ParallelHandler', (): void => {
+  const handlers: jest.Mocked<AsyncHandler<string, string>>[] = [
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('0'),
+    },
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('1'),
+    },
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('2'),
+    },
+  ] as any;
+  const composite: ParallelHandler<string, string> = new ParallelHandler<string, string>(handlers);
+
+  afterEach(jest.clearAllMocks);
+
+  describe('canHandle', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.canHandle('abc')).resolves.toBeUndefined();
+
+      expect(handlers[0].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].canHandle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].canHandle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one handler fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].canHandle.mockRejectedValueOnce(error);
+      await expect(composite.canHandle('abc')).rejects.toThrow(error);
+    });
+  });
+
+  describe('handle', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.handle('abc')).resolves.toEqual([ '0', '1', '2' ]);
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].handle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one handler fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].handle.mockRejectedValueOnce(error);
+      await expect(composite.handle('abc')).rejects.toThrow(error);
+    });
+  });
+
+  describe('handleSafe', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.handleSafe('abc')).resolves.toEqual([ '0', '1', '2' ]);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].canHandle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].canHandle).toHaveBeenCalledWith('abc');
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].handle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one canHandle fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].canHandle.mockRejectedValueOnce(error);
+      await expect(composite.handleSafe('abc')).rejects.toThrow(error);
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(0);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(0);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(0);
+    });
+
+    it('fails if one handle fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].handle.mockRejectedValueOnce(error);
+      await expect(composite.handleSafe('abc')).rejects.toThrow(error);
+    });
+  });
+});
diff --git a/test/unit/util/SequenceHandler.test.ts b/test/unit/util/handlers/SequenceHandler.test.ts
similarity index 93%
rename from test/unit/util/SequenceHandler.test.ts
rename to test/unit/util/handlers/SequenceHandler.test.ts
index 8c6be9959..b13f4983c 100644
--- a/test/unit/util/SequenceHandler.test.ts
+++ b/test/unit/util/handlers/SequenceHandler.test.ts
@@ -1,5 +1,5 @@
-import type { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { SequenceHandler } from '../../../src/util/SequenceHandler';
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { SequenceHandler } from '../../../../src/util/handlers/SequenceHandler';
 
 describe('A SequenceHandler', (): void => {
   const handlers: jest.Mocked<AsyncHandler<string, string>>[] = [
diff --git a/test/unit/util/UnsupportedAsyncHandler.test.ts b/test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
similarity index 80%
rename from test/unit/util/UnsupportedAsyncHandler.test.ts
rename to test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
index 7866b9c0e..ae46cd2c5 100644
--- a/test/unit/util/UnsupportedAsyncHandler.test.ts
+++ b/test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
@@ -1,5 +1,5 @@
-import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
-import { UnsupportedAsyncHandler } from '../../../src/util/UnsupportedAsyncHandler';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { UnsupportedAsyncHandler } from '../../../../src/util/handlers/UnsupportedAsyncHandler';
 
 describe('An UnsupportedAsyncHandler', (): void => {
   it('throws a default error when no message is set.', async(): Promise<void> => {
diff --git a/test/unit/util/WaterfallHandler.test.ts b/test/unit/util/handlers/WaterfallHandler.test.ts
similarity index 92%
rename from test/unit/util/WaterfallHandler.test.ts
rename to test/unit/util/handlers/WaterfallHandler.test.ts
index d86d4ff5e..1bb20f4b2 100644
--- a/test/unit/util/WaterfallHandler.test.ts
+++ b/test/unit/util/handlers/WaterfallHandler.test.ts
@@ -1,8 +1,8 @@
-import type { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
-import { HttpError } from '../../../src/util/errors/HttpError';
-import { WaterfallHandler } from '../../../src/util/WaterfallHandler';
-import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
+import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
+import { HttpError } from '../../../../src/util/errors/HttpError';
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { WaterfallHandler } from '../../../../src/util/handlers/WaterfallHandler';
+import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler';
 
 describe('A WaterfallHandler', (): void => {
   describe('with no handlers', (): void => {
diff --git a/test/util/StaticAsyncHandler.ts b/test/util/StaticAsyncHandler.ts
index 2e3946411..c482285d5 100644
--- a/test/util/StaticAsyncHandler.ts
+++ b/test/util/StaticAsyncHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../src/util/AsyncHandler';
+import { AsyncHandler } from '../../src/util/handlers/AsyncHandler';
 
 export class StaticAsyncHandler<TOut> extends AsyncHandler<any, TOut> {
   private readonly canHandleStatic: boolean;