Handling Neo4j Constraint Errors with Nest Interceptors

Adam Cowley
Neo4j Developer Blog
6 min readAug 10, 2020

This post is the third in a series of blog posts that accompany the Twitch stream on the Neo4j Twitch channel where I build an application on top of Neo4j with NestJS.

This article assumes some prior knowledge of Neo4j and Nest.js. If you haven’t already done so, you can read the first three articles at:

  1. Building a Web Application with Neo4j and Nest.js
  2. Authentication in a Nest.js Application with Neo4j
  3. Authorising Requests in Nest.js with Neo4j

Unique Constraints

The definition of insanity is doing the same thing over and over again, but expecting different results.
- Albert Einstein

One thing that we haven’t tested yet is that our constraints are correctly handled in our API. You may recall that while implementing the Authentication functionality, we created a unique constraint to ensure that the email property was unique for any node with a User label:

CREATE CONSTRAINT ON (u:User) ASSERT u.email IS UNIQUE

We can simulate the behaviour of two users attempting to sign up with the same email address by using Cypher’s range function to generate a collection with two numbers and using UNWIND to unpack them on to their own rows.

UNWIND range(1, 2) AS row
CREATE (u:User {email: "duplicate@email.com"})

Running a CREATE command with the same email address will cause Neo4j to throw a Client Error

Neo.ClientError.Schema.ConstraintValidationFailed
Node(54776) already exists with label `User` and property `email` = 'duplicate@email.com'

Similarly, if we create an exists constraint,

CREATE CONSTRAINT ON (t:Test) ASSERT exists(t.mustExist);CREATE (:Test)

This will also return a ConstraintValidationFailed error, but instead with a different error message:

Neo.ClientError.Schema.ConstraintValidationFailed
Node(54778) with label `Test` must have the property `mustExist`

The JavaScript driver will instantiate these errors as a Neo4jError. If this, or any other Error is thrown during the request lifecycle, it will be caught by an Exception Filter.

There are a number of built in Errors that the Exception Filter will recognise, but by default the application will return a HTTP 500 error code, representing an Internal Server Error. This is a generic response to signify that something has gone wrong on the server, rather than it being the cause of the request.

Instead, because this is an error caused by the input, we should provide a response similar to the one provided by the ValidationPipe that checks the response against the directorators of the CreateUserDto Data Transfer Object.

To do this, we can create an Exception filter.

@Catching the Error

An Exception Filter is a class decorated with the @Catch decorator (imported from @nestjs/common) - the decorator accepts many arguments representing the type of Error that should be caught by the filter. The class implements the ExceptionFilter class exported from @nestjs/core and contains a catch method. This method takes the error object that has been thrown in the application, and an instance of ArgumentsHost which allows us to get the Request and Response objects.

The Nest CLI has a method for generating filters: nest generate filter {name}. The command generates the file inside the current directory, so we'll have to navigate into the correct folder before running the command:

cd src/neo4j
nest g f neo4j-error

If we replace the default error in the generated class with Neo4jError and update the type of the first argument in the catch method we should have a class similar to this.

// neo4j-error.interceptor.ts
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { Request, Response } from 'express';
import { Neo4jError } from 'neo4j-driver';
@Catch(Neo4jError)
export class Neo4jErrorFilter implements ExceptionFilter {
catch(exception: Neo4jError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// ... response
.status(statusCode)
.json({
statusCode,
message,
error,
});
}
}

Because all of the errors thrown from Neo4j instantiate the same class, we’ll have to use the message property to work out what the error is. Based on the error messages earlier, we can tell that a string containing already exists with will indicate that the error is due to the unique constraint, and a string containing must have the property means that the query has failed on an exists constraint.

if ( exception.message.includes('already exists with') ) {
// The value supplied isn't unique
}
else if ( exception.message.includes('must have the property') ) {
// Fails the exists constraint
}

Neo4j will throw a variety of errors from anything from failed connection to the server to things like Cypher syntax errors. For this reason, we should treat all other cases as a 500 Internal Error.

We can see from both error messages that the label and property name are contained in backticks:

Node(54776) already exists with label `User` and property `email` = 'duplicate@email.com'
Node(54778) with label `Test` must have the property `mustExist`

So we can use the match function to extract a regex pattern with anything.

const [ label, property ] = exception.message.match(/`([a-z0-9]+)`/gi)

The /gi flags at the end of the statement tell the function that it should run a global match (g) and return an array of matched values and the i flag denotes that the pattern is case insensitive.

As the function will return an array of values, and we know that the order will be correct, we can use destructuring to pass the label and property straight into a variable.

All that is left is to update the status code, error, and message :

let statusCode = 500
let error = 'Internal Server Error'
let message: string[] = undefined
// Neo.ClientError.Schema.ConstraintValidationFailed
// Node(54776) already exists with label `User` and property `email` = 'duplicate@email.com'
if ( exception.message.includes('already exists with') ) {
statusCode = 400
error = 'Bad Request'
const [ label, property ] = exception.message.match(/`([a-z0-9]+)`/gi)
message = [`${property.replace(/`/g, '')} already taken`]
}
// Neo.ClientError.Schema.ConstraintValidationFailed
// Node(54778) with label `Test` must have the property `mustExist`
else if ( exception.message.includes('must have the property') ) {
statusCode = 400
error = 'Bad Request'
const [ label, property ] = exception.message.match(/`([a-z0-9]+)`/gi)
message = [`${property.replace(/`/g, '')} should not be empty`]
}
response
.status(statusCode)
.json({
statusCode,
error,
message,
});

The final thing that is needed is to add this as a global filter in main.ts:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { Neo4jTypeInterceptor } from './neo4j/neo4j-type.interceptor';
// Import the new Neo4jErrorFilter class
import { Neo4jErrorFilter } from './neo4j/neo4j-error.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.useGlobalInterceptors(new Neo4jTypeInterceptor());
// Use the Neo4j Error Filter on all rooutes
app.useGlobalFilters(new Neo4jErrorFilter());
await app.listen(3000);
}
bootstrap();

Testing the Filter

To ensure that this works correctly, we can add a new test case to the end-to-end tests in app.e2e-spec.ts. Firstly, in the beforeEach hook, we need to register the global filter on the test application:

// app.e2e-spec.ts
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
app.useGlobalInterceptors(new Neo4jTypeInterceptor());
app.useGlobalFilters(new Neo4jErrorFilter());
await app.init();
});

Then we can copy down the same test from should return HTTP 200 successful on successful registration. Because these tests are run in sequence, we can guarantee that the user has been created in the first test before the second is run.

We’ll need to update the status code to be 400 instead of 201, and the body of the response should include an array of error messages including one saying that the email address has already been taken:

it('should return HTTP 200 successful on successful registration', () => {
return request(app.getHttpServer())
.post('/auth/register')
.set('Accept', 'application/json')
.send({
email,
password,
dateOfBirth: '2000-01-01',
firstName: 'Adam',
lastName: 'Cowley'
})
.expect(201)
.expect(res => {
expect(res.body.access_token).toBeDefined()
})
})
it('should return HTTP 400 when email is already taken', () => {
return request(app.getHttpServer())
.post('/auth/register')
.set('Accept', 'application/json')
.send({
email,
password,
dateOfBirth: '2000-01-01',
firstName: 'Adam',
lastName: 'Cowley'
})
// Should return 400 instead of 201
.expect(400)
.expect(res => {
// The body should return the `already taken` error
expect(res.body.message).toContain('email already taken')
})
})

If everything has been registered successfully, the npm run test:e2e should report that all of our tests are passing:

npm run test:e2e> api@0.0.1 test:e2e /Users/adam/projects/twitch/api
> jest --detectOpenHandles --config ./test/jest-e2e.json
PASS test/app.e2e-spec.ts (5.041 s)
AppController (e2e)
Auth
POST /auth/register
✓ should validate the request (347 ms)
✓ should return HTTP 200 successful on successful registration (108 ms)
✓ should return HTTP 400 when email is already taken (106 ms)
POST /auth/login
✓ should return 401 if username does not exist (49 ms)
✓ should return 401 if password is incorrect (104 ms)
✓ should return 201 if username and password are correct (98 ms)

Conclusion

Now that we are returning the correct response to errors caused by database constraints, the UI be able to treat these errors the same as it would with the validation errors returned by the ValidationPipe. As this error is also fully expected, we can reduce the amount internal server errors sent to our monitoring tools and cut down on the amount of debugging time.

Futher Reading

If you have any questions, comments or if you would like to see a feature added to the API, feel free to open a Github Issue.

Join me Tuesdays at 12:00 UTC / 13:00BST / 14:00CEST / 15:30 IST on the Neo4j Twitch Channel for the next session.

--

--