Batch calls with Business Central (2) – Error handling

This is the second post in a batch series about batch calls with Business Central APIs. You can find the first one about the basic operations with batch calls here. In this post, I want to cover one of the most frequently asked questions about batch calls: what happens if one of the operations runs into an error? Will it stop further execution, will the already processed operations be rolled back? Let’s have a look.

It’s very important about batch requests to understand that the response status is available on two levels: on the overall batch request and on each individual operation in the batch. For a single, direct API call you get the status back on the response level. Then you check if the status is in the 200 series to see if the call was successful. The response status of the batch request however does not indicate if the individual operations in the batch were successful or not. You will get back status 200 if the $batch endpoint did receive the request and was able to read it. A response status other than 200 indicates a malformed request body, or not authorized, etc. But if the $batch was able to parse the request body, then you will find the results of each individual operation in the response body. See the example response body in the first post, where each individual response has status 201 (created).

So, what happens if there is an error? The default behavior for Business Central APIs is that further execution will be stopped and the returned response contains the results up to the operation that failed. Let’s take the same example batch request from the first post to create three journal lines, but now with an invalid date in the second operation.

{
	"requests": [
		{
			"method": "POST",
			"id": "r1",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3250,
			    "description": "Salary to Bob"
			}
		},
		{
			"method": "POST",
			"id": "r2",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20x",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3500,
			    "description": "Salary to John"
	        }
		},
        {
			"method": "POST",
			"id": "r3",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId2}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": 6750,
			    "description": "Salaries December 2020"
	        }
		}	
	]
}

The response looks like this:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json
Content-Encoding: gzip
Server: Microsoft-HTTPAPI/2.0
OData-Version: 4.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Date, Content-Length, Server, OData-Version
request-id: ae550433-524b-40bc-96d1-6f4efc8651dc
Date: Mon, 21 Dec 2020 22:46:36 GMT
{
    "responses": [
        {
            "id": "r1",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines(3c9b67d0-0c41-eb11-a853-d0e7bcc597da)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines/$entity",
                "@odata.etag": "W/\"JzQ0O0RwczFRK2dKNlhPZlhINUl5bGgzR3ZvVUdhRnYrZUZvTU4wUzVVeU54QWM9MTswMDsn\"",
                "id": "3c9b67d0-0c41-eb11-a853-d0e7bcc597da",
                "journalId": "f91409ba-1d3d-eb11-bb72-000d3a2b9218",
                "journalDisplayName": "DEFAULT",
                "lineNumber": 130000,
                "accountType": "G_x002F_L_x0020_Account",
                "accountId": "ae4110b4-1d3d-eb11-bb72-000d3a2b9218",
                "accountNumber": "60700",
                "postingDate": "2020-10-20",
                "documentNumber": "SALARY2020-12",
                "externalDocumentNumber": "",
                "amount": -3250.00,
                "description": "Salary to Bob",
                "comment": "",
                "taxCode": "NONTAXABLE",
                "balanceAccountType": "G_x002F_L_x0020_Account",
                "balancingAccountId": "00000000-0000-0000-0000-000000000000",
                "balancingAccountNumber": "",
                "lastModifiedDateTime": "2020-12-18T08:41:26.68Z"
            }
        },
        {
            "id": "r2",
            "status": 400,
            "headers": {
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "error": {
                    "code": "BadRequest",
                    "message": "Cannot convert the literal '2020-10-20x' to the expected type 'Edm.Date'.  CorrelationId:  f6123128-1db7-4ee8-9a79-469d713e1e54."
                }
            }
        }
    ]
}

The response status of the batch call itself was 200, indicating a success! Obviously, you need to read the response body to figure if an operation failed and also which operation didn’t execute at all (they are missing in the response body). There might be scenarios where this is perfectly fine, but in most cases, you want to continue to process the other operations or to completely roll back the whole batch. Let’s explore these two options.

Continue on error

The first option is to continue to process the additional requests in the batch. This can be requested by means of the Prefer: odata.continue-on-error header.

POST {{baseurl}}/api/v2.0/$batch
Content-Type: application/json
Accept: application/json
Prefer: odata.continue-on-error

Now the response body contains a result for all operations in the batch:

{
    "responses": [
        {
            "id": "r1",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines(6fea4f2b-1041-eb11-a853-d0e7bcc597da)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines/$entity",
                "@odata.etag": "W/\"JzQ0O05nSVI2ZVU1NVpXazQySUlBS2dUbHVlK0dhUElJb2hRYjZqbk12ZUxrbk09MTswMDsn\"",
                "id": "6fea4f2b-1041-eb11-a853-d0e7bcc597da",
                "journalId": "f91409ba-1d3d-eb11-bb72-000d3a2b9218",
                "journalDisplayName": "DEFAULT",
                "lineNumber": 140000,
                "accountType": "G_x002F_L_x0020_Account",
                "accountId": "ae4110b4-1d3d-eb11-bb72-000d3a2b9218",
                "accountNumber": "60700",
                "postingDate": "2020-10-20",
                "documentNumber": "SALARY2020-12",
                "externalDocumentNumber": "",
                "amount": -3250.00,
                "description": "Salary to Bob",
                "comment": "",
                "taxCode": "NONTAXABLE",
                "balanceAccountType": "G_x002F_L_x0020_Account",
                "balancingAccountId": "00000000-0000-0000-0000-000000000000",
                "balancingAccountNumber": "",
                "lastModifiedDateTime": "2020-12-18T09:05:27.69Z"
            }
        },
        {
            "id": "r2",
            "status": 400,
            "headers": {
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "error": {
                    "code": "BadRequest",
                    "message": "Cannot convert the literal '2020-10-20x' to the expected type 'Edm.Date'.  CorrelationId:  b3bd539c-729f-4a20-9c8e-3868917d0283."
                }
            }
        },
        {
            "id": "r3",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines(70ea4f2b-1041-eb11-a853-d0e7bcc597da)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(9f161476-1d3d-eb11-bb72-000d3a2b9218)/journals(f91409ba-1d3d-eb11-bb72-000d3a2b9218)/journalLines/$entity",
                "@odata.etag": "W/\"JzQ0O3ZqVHpoVm12UGd2NEQ0Tk1NM0lYSnVMUlorZDlpS2dYUk5xWDNyQmpJSzQ9MTswMDsn\"",
                "id": "70ea4f2b-1041-eb11-a853-d0e7bcc597da",
                "journalId": "f91409ba-1d3d-eb11-bb72-000d3a2b9218",
                "journalDisplayName": "DEFAULT",
                "lineNumber": 150000,
                "accountType": "G_x002F_L_x0020_Account",
                "accountId": "844110b4-1d3d-eb11-bb72-000d3a2b9218",
                "accountNumber": "20700",
                "postingDate": "2020-10-20",
                "documentNumber": "SALARY2020-12",
                "externalDocumentNumber": "",
                "amount": 6750.00,
                "description": "Salaries December 2020",
                "comment": "",
                "taxCode": "NONTAXABLE",
                "balanceAccountType": "G_x002F_L_x0020_Account",
                "balancingAccountId": "00000000-0000-0000-0000-000000000000",
                "balancingAccountNumber": "",
                "lastModifiedDateTime": "2020-12-18T09:05:27.737Z"
            }
        }
    ]
}

As you can see, the second operation has status 400, while operations r1 and r3 do have the expected status 201.

Let’s move on to transactional behavior.

Transactional

The other scenario is to rollback all operations. The batch is handled as one transaction. The OData standard has a feature for this, but this feature is not implemented by Business Central. Luckily, there is an alternative. Let’s first look at the standard OData feature, just in case you come across it and wonder why it doesn’t work. The standard feature is called ‘atomicity group’ or ‘changeset’. This is an additional property of the operation to indicate multiple operations that must be processed as an atomic operation and must either all succeed or all will fail. Here is an example of the batch request body with all operations in one atomicity group (the OData specification allows for multiple atomicity groups in a batch request):

{
	"requests": [
		{
			"method": "POST",
            "atomicityGroup": "group1",
			"id": "r1",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3250,
			    "description": "Salary to Bob"
			}
		},
		{
			"method": "POST",
            "atomicityGroup": "group1",
			"id": "r2",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3500,
			    "description": "Salary to John"
	        }
		},
        {
			"method": "POST",
            "atomicityGroup": "group1",
			"id": "r3",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId2}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": 6750,
			    "description": "Salaries December 2020"
	        }
		}
    ]
}

Unfortunately, when you send this call to Business Central, the response has status 500 and this response body:

{
    "error": {
        "code": "BadRequest_NotSupported",
        "message": "Multiple requests within the same change set are not supported by Microsoft Dynamics 365 Business Central OData web services.  CorrelationId:  0b74f9b1-f9f1-42fa-bcf3-6fc8879d6bb8."
    }
}

The reason to not support this feature is because it’s quite hard to implement it. Each operation is individually processed and committed to the database. Rolling back multiple committed transactions is not easy, also when you consider that other processes can do modifications simultaneously.

Alternative: Isolation

The alternative is to enable transactional batch behavior with the header Isolation: snapshot. If one of the operations fails, then all committed changes from the operations in the same batch will be rolled back. Microsoft documented this feature here: https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-connect-apps-tips#batch. This is how the headers look like with the Isolation header:

POST {{baseurl}}/api/v2.0/$batch
Content-Type: application/json
Accept: application/json
Isolation: snapshot

What is quite strange, is that the Isolation header seems to be a non-standard header. I couldn’t find any official documentation about an OData header with the name Isolation. However, there is a standard OData header called OData-Isolation, with snapshot as the only supported value. And guess what, this official header has the same effect. I really wonder why Microsoft came up with a non-standard header while there is a standard header available.

The isolation header is officially not designed for implementing transactional behavior. So what’s the deal with isolation, why does it work? Snapshot isolation makes sure that the API request only returns data that is a result of the API call. No data from other processes can be included. Which theoretically can happen with a batch request, because the operations are processed and committed individually. Consecutive operations in a batch request could return data that has been modified by another process. The snapshot isolation prevents that, all operations will work on the database as it was at the start of the request plus all modifications from the request itself. By the way, this also works for single requests, not only for batch requests.

Apparently, this behavior has been taken as an opportunity to roll back the isolated modifications if any operation in the batch fails. Again, it’s not what it was intended for, and I would definitely like to see the atomicity group instead of this workaround. It’s the next best option, but not ideal.

What does the response look like when snapshot isolation has been applied? Surprisingly, there is no difference with the previous examples. The batch request will either fail on the first error or continue, depending on the Prefer:odata.continue-on-error header. But if there was an error, then it will rollback all committed changes.

Recommendation

My recommendation is to combine those two headers. With snapshot isolation, you get transactional behavior. And with continue-on-error you will get a full list of all failing operations instead of only the first one. The headers of the combined call would look like this (using the official OData header for isolation):

POST {{baseurl}}/api/v2.0/$batch
Content-Type: application/json
Accept: application/json
Prefer: odata.continue-on-error
OData-Isolation: snapshot
Comment List
Related
Recommended