Repository: microsoft/mssql-jdbc
Pull request #2251 (2023-11-13 18:35:22)

Server Message Handler and SQLException Chaining
This is a solution to issue https://github.com/microsoft/mssql-jdbc/issues/1299   

## ServerMessageHandler
You can use the message handler for the following
* "message feedback"
   *  Display Server messages from a long running SQL Statement... Like `RAISERROR ('Progress message...', 0, 1) WITH NOWAIT`   
   * Or Status messages from a running backup...
* "Universal" error logging
  * Your error-message handler can contain the logic for handling all error logging
* "Universal" error handling
  * Error-handling logic can be placed in your error-message handler, instead of being repeated throughout your application.
* Remapping of error-message severity (based on application requirements)
   * Your error-message handler can contain logic for recognizing specific error messages, and downgrading or upgrading their severity based on application considerations rather than the severity rating of the server.   
   * For example, during a cleanup operation that deletes old "stuff", you might want to downgrade the severity of "bad things".    
      However, you may want to upgrade the severity in other circumstances.
   * If you write a "SQL Query Tool" (like SSMS) you might not want the Errors to terminate current SQL Batch (throw SQLException), but instead "map" the errors as a "warning"...

##  SQLException Chaining
I also found out that Exceptions do not contains any "extra messages" that is produced by the SQL Server.   
For example when creating a stored proc with several errors in it, you will only see the first problem...
So this just adds any "extra" Messages to the first `SQLWarning` by `setNextException`


microsoft-github-policy-service[bot]: 
@goranschwarz please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

```
@microsoft-github-policy-service agree [company="{your company}"]
```

> Options:
> - (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
>```
>@microsoft-github-policy-service agree
>```
> - (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
>```
>@microsoft-github-policy-service agree company="Microsoft"
>```

<details>
    <summary>Contributor License Agreement</summary>
    
### Contribution License Agreement
This Contribution License Agreement (**“Agreement”**) is agreed to by the party signing below (**“You”**),
and conveys certain license rights to Microsoft Corporation and its affiliates (“Microsoft”) for Your
contributions to Microsoft open source projects. This Agreement is effective as of the latest signature
date below.
1. **Definitions**.
**“Code”** means the computer software code, whether in human-readable or machine-executable form,
that is delivered by You to Microsoft under this Agreement.
**“Project”** means any of the projects owned or managed by Microsoft and offered under a license
approved by the Open Source Initiative (www.opensource.org).
**“Submit”** is the act of uploading, submitting, transmitting, or distributing code or other content to any
Project, including but not limited to communication on electronic mailing lists, source code control
systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of
discussing and improving that Project, but excluding communication that is conspicuously marked or
otherwise designated in writing by You as “Not a Submission.”
**“Submission”** means the Code and any other copyrightable material Submitted by You, including any
associated comments and documentation.
2. **Your Submission**. You must agree to the terms of this Agreement before making a Submission to any
Project. This Agreement covers any and all Submissions that You, now or in the future (except as
described in Section 4 below), Submit to any Project.
3. **Originality of Work**. You represent that each of Your Submissions is entirely Your original work.
Should You wish to Submit materials that are not Your original work, You may Submit them separately
to the Project if You (a) retain all copyright and license information that was in the materials as You
received them, (b) in the description accompanying Your Submission, include the phrase “Submission
containing materials of a third party:” followed by the names of the third party and any licenses or other
restrictions of which You are aware, and (c) follow any other instructions in the Project’s written
guidelines concerning Submissions.
4. **Your Employer**. References to “employer” in this Agreement include Your employer or anyone else
for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your
Submission is made in the course of Your work for an employer or Your employer has intellectual
property rights in Your Submission by contract or applicable law, You must secure permission from Your
employer to make the Submission before signing this Agreement. In that case, the term “You” in this
Agreement will refer to You and the employer collectively. If You change employers in the future and
desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement
and secure permission from the new employer before Submitting those Submissions.
5. **Licenses**.
  * **Copyright License**. You grant Microsoft, and those who receive the Submission directly or
indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license in the
Submission to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute
the Submission and such derivative works, and to sublicense any or all of the foregoing rights to third
parties.
  * **Patent License**. You grant Microsoft, and those who receive the Submission directly or
indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license under
Your patent claims that are necessarily infringed by the Submission or the combination of the
Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and
import or otherwise dispose of the Submission alone or with the Project.
  * **Other Rights Reserved**. Each party reserves all rights not expressly granted in this Agreement.
No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are
granted by implication, exhaustion, estoppel or otherwise.
6. **Representations and Warranties**. You represent that You are legally entitled to grant the above
licenses. You represent that each of Your Submissions is entirely Your original work (except as You may
have disclosed under Section 3). You represent that You have secured permission from Your employer to
make the Submission in cases where Your Submission is made in the course of Your work for Your
employer or Your employer has intellectual property rights in Your Submission by contract or applicable
law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You
have the necessary authority to bind the listed employer to the obligations contained in this Agreement.
You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS
REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES
EXPRESSLY STATED IN SECTIONS 3, 4, AND 6, THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS
PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF
NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7. **Notice to Microsoft**. You agree to notify Microsoft in writing of any facts or circumstances of which
You later become aware that would make Your representations in this Agreement inaccurate in any
respect.
8. **Information about Submissions**. You agree that contributions to Projects and information about
contributions may be maintained indefinitely and disclosed publicly, including Your name and other
information that You submit with Your Submission.
9. **Governing Law/Jurisdiction**. This Agreement is governed by the laws of the State of Washington, and
the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County,
Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to
exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all
defenses of lack of personal jurisdiction and forum non-conveniens.
10. **Entire Agreement/Assignment**. This Agreement is the entire agreement between the parties, and
supersedes any and all prior agreements, understandings or communications, written or oral, between
the parties relating to the subject matter hereof. This Agreement may be assigned by Microsoft.

</details>
<!-- Policy app identification https://img.shields.io/static/v1?label=ContributorLicenseAgreement. -->
goranschwarz: @microsoft-github-policy-service agree
goranschwarz: @microsoft-github-policy-service agree

From: microsoft-github-policy-service[bot] ***@***.***>
Sent: Monday, November 13, 2023 7:36 PM
To: microsoft/mssql-jdbc ***@***.***>
Cc: Goran Schwarz ***@***.***>; Mention ***@***.***>
Subject: Re: [microsoft/mssql-jdbc] Server Message Handler and SQLException Chaining (PR #2251)


@goranschwarz<https://github.com/goranschwarz> please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  *   (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.

@microsoft-github-policy-service agree

  *   (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.

@microsoft-github-policy-service agree company="Microsoft"
Contributor License Agreement
Contribution License Agreement

This Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
and conveys certain license rights to Microsoft Corporation and its affiliates (“Microsoft”) for Your
contributions to Microsoft open source projects. This Agreement is effective as of the latest signature
date below.

  1.  Definitions.
“Code” means the computer software code, whether in human-readable or machine-executable form,
that is delivered by You to Microsoft under this Agreement.
“Project” means any of the projects owned or managed by Microsoft and offered under a license
approved by the Open Source Initiative (www.opensource.org<http://www.opensource.org>).
“Submit” is the act of uploading, submitting, transmitting, or distributing code or other content to any
Project, including but not limited to communication on electronic mailing lists, source code control
systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of
discussing and improving that Project, but excluding communication that is conspicuously marked or
otherwise designated in writing by You as “Not a Submission.”
“Submission” means the Code and any other copyrightable material Submitted by You, including any
associated comments and documentation.
  2.  Your Submission. You must agree to the terms of this Agreement before making a Submission to any
Project. This Agreement covers any and all Submissions that You, now or in the future (except as
described in Section 4 below), Submit to any Project.
  3.  Originality of Work. You represent that each of Your Submissions is entirely Your original work.
Should You wish to Submit materials that are not Your original work, You may Submit them separately
to the Project if You (a) retain all copyright and license information that was in the materials as You
received them, (b) in the description accompanying Your Submission, include the phrase “Submission
containing materials of a third party:” followed by the names of the third party and any licenses or other
restrictions of which You are aware, and (c) follow any other instructions in the Project’s written
guidelines concerning Submissions.
  4.  Your Employer. References to “employer” in this Agreement include Your employer or anyone else
for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your
Submission is made in the course of Your work for an employer or Your employer has intellectual
property rights in Your Submission by contract or applicable law, You must secure permission from Your
employer to make the Submission before signing this Agreement. In that case, the term “You” in this
Agreement will refer to You and the employer collectively. If You change employers in the future and
desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement
and secure permission from the new employer before Submitting those Submissions.
  5.  Licenses.

  *   Copyright License. You grant Microsoft, and those who receive the Submission directly or
indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license in the
Submission to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute
the Submission and such derivative works, and to sublicense any or all of the foregoing rights to third
parties.
  *   Patent License. You grant Microsoft, and those who receive the Submission directly or
indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license under
Your patent claims that are necessarily infringed by the Submission or the combination of the
Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and
import or otherwise dispose of the Submission alone or with the Project.
  *   Other Rights Reserved. Each party reserves all rights not expressly granted in this Agreement.
No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are
granted by implication, exhaustion, estoppel or otherwise.

  1.  Representations and Warranties. You represent that You are legally entitled to grant the above
licenses. You represent that each of Your Submissions is entirely Your original work (except as You may
have disclosed under Section 3). You represent that You have secured permission from Your employer to
make the Submission in cases where Your Submission is made in the course of Your work for Your
employer or Your employer has intellectual property rights in Your Submission by contract or applicable
law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You
have the necessary authority to bind the listed employer to the obligations contained in this Agreement.
You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS
REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES
EXPRESSLY STATED IN SECTIONS 3, 4, AND 6, THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS
PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF
NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
  2.  Notice to Microsoft. You agree to notify Microsoft in writing of any facts or circumstances of which
You later become aware that would make Your representations in this Agreement inaccurate in any
respect.
  3.  Information about Submissions. You agree that contributions to Projects and information about
contributions may be maintained indefinitely and disclosed publicly, including Your name and other
information that You submit with Your Submission.
  4.  Governing Law/Jurisdiction. This Agreement is governed by the laws of the State of Washington, and
the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County,
Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to
exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all
defenses of lack of personal jurisdiction and forum non-conveniens.
  5.  Entire Agreement/Assignment. This Agreement is the entire agreement between the parties, and
supersedes any and all prior agreements, understandings or communications, written or oral, between
the parties relating to the subject matter hereof. This Agreement may be assigned by Microsoft.

—
Reply to this email directly, view it on GitHub<https://github.com/microsoft/mssql-jdbc/pull/2251#issuecomment-1808775727>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/ABCGRLL27YUCRK2I43L2SCLYEJR7ZAVCNFSM6AAAAAA7JS57O2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTQMBYG43TKNZSG4>.
You are receiving this because you were mentioned.Message ID: ***@***.******@***.***>>

tkyc: Thanks for the contribution. In case review gets a bit delayed, just to let you know PR is on our radar.
goranschwarz: Thanks for the update, it would be nice to have it in release 12.5.0, if possible ;)
Jeffery-Wasty: Hi @goranschwarz,

With our 12.5.0 release planned for today, that doesn't look likely. The next release to include these changes will likely be our 12.6.0 GA release, slated for January 31, 2024.
Jeffery-Wasty: Adding on to my previous comment. This PR will NOT be included for the January 31 GA release. We'll look into including it in one of the following stable releases after 12.6.0.
Jeffery-Wasty: Hi @goranschwarz,

This PR introduces a major change, that we consider to be a breaking change. It's also fairly large. These factors necessitate a approach to testing and review, different than what we would use with smaller PRs. With release planned for January 31, this gives us 7 weeks until a code freeze, plenty of time, but the team is currently engaged with other, high profile items. Its unclear when we would be able to start testing and reviewing, and we wouldn't want to rush such a large enhancement.  We'll try to get to this as soon as we can, we don't want a community-contributed PR to sit for an extended amount of time, but timing-wise, it doesn't look like this can make our 12.6.0 GA release.
goranschwarz: OK; That sounds fare!

Let me know if I can help in any way...
lilgreenbird: as mentioned above, this isn't going to make the GA release. I've put this into the backlog for now we will look at this again after the Jan release.
tkyc: Looks good in our internal pipelines. But, it looks like exception chaining isn't working for the scenario described in #2115. From debugging, it looks like the TDS stream is returning only one error message and so only one error message is set. 
goranschwarz: I wanted to change as little as possible!  
The code for `Server Message Handler` is just "hooking into" the current code, **without** changing the *flow* of how things are processed (TDS Streaming wise)

@tkyc Regarding `SQLException chaining`
* The code in the PR will only work for commands that returns several Errors in **one** SQL Command   
   (and nested stored procedure calls is **not** "one" command if you look at the TDS Stream, and how we are currently processing it.)

To solve #2115 (several error messages in nested procedures), or multiple TDS_ERR in the TDS Stream  
We need to do **a lot** more changes (below is possibly why)
* SQLExceptions are thrown **early**, when reading the TDS Stream
* and on `stmnt.close()` we read the **remaining TDS Stream**, which contains those Error Messages (but at that time we have already thrown the SQLException)
So we need to *read out* more of the TDS Stream before SQLExceptions are throws...   
And to do that I think **a lot** of changes and **tests** are needed

What I'm trying to accomplish with the "Server Message Handler", is to be **able** to **downgrade** a `SQLException` to a `SQLWarning`   
(hence trying to be more like SSMS, meaning: continue to read the Data/Messages without aborting (by an SQLException) until the SQL Batch is complete)

### I did some test of the "nested" procedure errors (with some debug printing in the MS JDBC Driver)
This to trace what TDS Stream Tokens that was received (hence where did the Error messages go...)   
   
Simple `stmnt.executeUpdate(sql)` code
Note: I also tested with `stmnt.executeQuery(sql)` and `stmnt.execute(sql)`   
And they shows the "same" output as below   

Test code (I can post the full if you want to):
```
			System.out.println("");
			System.out.println("#####################################################");
			System.out.println("### executeUpdate('" + sql + "')");
			System.out.println("#####################################################");
			Statement stmnt = conn.createStatement();
			try //(Statement stmnt = conn.createStatement())
			{
				stmnt.executeUpdate(sql);

				SQLWarning ex = stmnt.getWarnings();
				while(ex != null)
				{
					System.out.println(">>> WARNING: Msg=" + ex.getErrorCode() + ", xxx=" + ex.getSQLState() + ", Text=|" + ex.getMessage() + "|.");
					ex = ex.getNextWarning();
				}
			}
			catch (SQLException ex)
			{
				while(ex != null)
				{
					System.out.println(">>> EXCEPTION: Msg=" + ex.getErrorCode() + ", xxx=" + ex.getSQLState() + ", Text=|" + ex.getMessage() + "|.");
					ex = ex.getNextException();
				}

				ex = stmnt.getWarnings();
				while(ex != null)
				{
					System.out.println(">>> WARNING: Msg=" + ex.getErrorCode() + ", xxx=" + ex.getSQLState() + ", Text=|" + ex.getMessage() + "|.");
					ex = ex.getNextException();
				}
			}
			finally {
				System.out.println("### stmnt.close()");
				stmnt.close();
			}
```
And it's output
```
#####################################################
### executeUpdate('exec p1 @doSelect = 0')
#####################################################
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=170, getTokenName=TDS_ERR (0xAA)
     - AT: TDSTokenHandler.addDatabaseError(): logContext='getNextResult', databaseError=P2 - inner EXCEPTION text. sev=16
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = true <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< parsing will be set to FALSE in the TDS-PARSE() loop.
>>> EXCEPTION: Msg=50000, xxx=S0001, Text=|P2 - inner EXCEPTION text. sev=16|.
### stmnt.close()
  -- TDS PARSE(): tdsTokenType=171, getTokenName=TDS_MSG (0xAB)
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=170, getTokenName=TDS_ERR (0xAA)
     - AT: TDSTokenHandler.addDatabaseError(): logContext='getNextResult', databaseError=P1 - P2 raised an EXCEPTION. sev=16
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = true <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< parsing will be set to FALSE in the TDS-PARSE() loop.
  -- TDS PARSE(): tdsTokenType=171, getTokenName=TDS_MSG (0xAB)
  -- TDS PARSE(): tdsTokenType=255, getTokenName=TDS_DONEINPROC (0xFF)
     - AT: NextResult.onDone(): doneToken.isError() = false
  -- TDS PARSE(): tdsTokenType=121, getTokenName=TDS_RET_STAT (0x79)
  -- TDS PARSE(): tdsTokenType=254, getTokenName=TDS_DONEPROC (0xFE)
  -- TDS PARSE(): tdsTokenType=-1, getTokenName=unknown token (0xFFFFFFFF)
```


### Extra info trying to "fix" that we Read more TDS Tokens before Throwing SQLException
Yes I did some test, which initially looked good but I "scrapped" them pretty quickly
The change was to comment out the below line at: SQLServerStatement.java, approx row: 1587
```
                // If the current command (whatever it was) produced an error then stop parsing and propagate it up.
                // In this case, the command is likely to be a RAISERROR, but it could be anything.
System.out.println("     - AT: NextResult.onDone(): doneToken.isError() = " + doneToken.isError() + (doneToken.isError() ? " <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< parsing will be set to FALSE in the TDS-PARSE() loop." : ""));
//                if (doneToken.isError())
//                    return false;

                // In all other cases, keep parsing
                return true;
```
Then it worked...
*BUT* When mixing a couple of `SELECT 'some text'` in the procedures it didn't work anymore...   

### For completeness here are the Stored procedure code
```
drop procedure p2
go
drop procedure p1
go
create procedure p2 (@doSelect int = 0)
as
begin
	if (@doSelect > 0)
		select 'SELECT at -- P2 - inner execution'

	raiserror('P2 - inner EXCEPTION text. sev=16', 16, 1) with nowait
	raiserror('P2 - inner WARNING text. sev=10', 10, 1) with nowait
--	return 0
	return 1
end
go
create procedure p1 (@doSelect int = 0)
as
begin
	declare @rc int

	if (@doSelect > 0)
		select 'SELECT at -- P1 - inner execution -- BEFORE'

	exec @rc=p2 @doSelect

	if (@doSelect > 0)
		select 'SELECT at -- P1 - inner execution -- AFTER'

	if (@rc != 0)
	begin
		raiserror('P1 - P2 raised an EXCEPTION. sev=16', 16, 1) with nowait
		raiserror('P1 - P2 warning an WARNING. sev=10', 10, 1) with nowait
	end
end
go
```

### For even more completeness here are a "path" of what I LOCALLY changed to get the DEBUG Prints
```
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
index b14f40e..ad0a17d 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
@@ -1583,6 +1583,7 @@
                 }
                 // If the current command (whatever it was) produced an error then stop parsing and propagate it up.
                 // In this case, the command is likely to be a RAISERROR, but it could be anything.
+System.out.println("     - AT: NextResult.onDone(): doneToken.isError() = " + doneToken.isError() + (doneToken.isError() ? " <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< parsing will be set to FALSE in the TDS-PARSE() loop." : ""));
                 if (doneToken.isError())
                     return false;
 
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java b/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
index 1a79a43..4a9a03b 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
@@ -64,6 +64,7 @@
         boolean isFeatureExtAck = false;
         while (parsing) {
             int tdsTokenType = tdsReader.peekTokenType();
+System.out.println("  -- TDS PARSE(): tdsTokenType="+tdsTokenType+", getTokenName="+com.microsoft.sqlserver.jdbc.TDS.getTokenName(tdsTokenType));
             if (isLogging) {
                 logger.finest(tdsReader.toString() + ": " + tdsTokenHandler.logContext + ": Processing "
                         + ((-1 == tdsTokenType) ? "EOF" : TDS.getTokenName(tdsTokenType)));
@@ -142,6 +143,7 @@
                     throwUnexpectedTokenException(tdsReader, tdsTokenHandler.logContext);
                     break;
             }
+//System.out.println("  " + (parsing ? "  " : "<<") + "            : parsing == " + parsing + (parsing ? "" : " <<<<<<<<<<<<<<< BREAKING WHILE LOOP <<<<<<<<<<<<<<<"));
         }
 
         // if TDS_FEATURE_EXTENSION_ACK is not received verify if TDS_FEATURE_EXT_AE was sent
@@ -198,6 +200,8 @@
     }
 
     public void addDatabaseError(SQLServerError databaseError) {
+System.out.println("     - AT: TDSTokenHandler.addDatabaseError(): logContext='"+this.logContext+"', databaseError="+databaseError.getErrorMessage());
+//new Exception("databaseError="+databaseError.getErrorMessage()).printStackTrace();
         if (this.databaseError == null) {
             this.databaseError = databaseError;
         } else {
@@ -247,6 +251,7 @@
     boolean onDone(TDSReader tdsReader) throws SQLServerException {
         StreamDone doneToken = new StreamDone();
         doneToken.setFromTDS(tdsReader);
+System.out.println("     - AT tdsParser.onDone(): doneToken.isFinal()="+doneToken.isFinal());
         if (doneToken.isFinal()) {
             // Response is completely processed hence decrement unprocessed response count.
             tdsReader.getConnection().getSessionRecovery().decrementUnprocessedResponseCount();
```


hmmm... This become longer than initially intended :)   
But I hope it made sense, and some clarity!


tkyc: @goranschwarz 

Thanks for the effort on the detailed response. That clarifies everything. Even though I've been on this project a while there's still things I do not know :smile:. I appreciate the help. Agreed, forget about the exception chaining case I mentioned. 

So far I don't have any other concerns. I'll urge my other team members to start looking at the PR as well.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) Report
Attention: Patch coverage is `59.16667%` with `49 lines` in your changes are missing coverage. Please review.
> Project coverage is 48.98%. Comparing base [(`eae6d7b`)](https://app.codecov.io/gh/microsoft/mssql-jdbc/commit/eae6d7b33571c92029169b33378b2405e8df448a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) to head [(`9c3fe8b`)](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft).

> :exclamation: Current head 9c3fe8b differs from pull request most recent head 73c6ad7. Consider uploading reports for the commit 73c6ad7 to get more accurate results

| [Files](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) | Patch % | Lines |
|---|---|---|
| [...a/com/microsoft/sqlserver/jdbc/SQLServerError.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlckVycm9yLmphdmE=) | 28.57% | [17 Missing and 3 partials :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...microsoft/sqlserver/jdbc/SQLServerInfoMessage.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlckluZm9NZXNzYWdlLmphdmE=) | 44.44% | [14 Missing and 1 partial :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...m/microsoft/sqlserver/jdbc/SQLServerException.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlckV4Y2VwdGlvbi5qYXZh) | 64.28% | [5 Missing :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...m/microsoft/sqlserver/jdbc/SQLServerStatement.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlclN0YXRlbWVudC5qYXZh) | 71.42% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [.../microsoft/sqlserver/jdbc/SQLServerConnection.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlckNvbm5lY3Rpb24uamF2YQ==) | 83.33% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...t/sqlserver/jdbc/SQLServerConnectionPoolProxy.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlckNvbm5lY3Rpb25Qb29sUHJveHkuamF2YQ==) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...com/microsoft/sqlserver/jdbc/SQLServerWarning.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL1NRTFNlcnZlcldhcm5pbmcuamF2YQ==) | 83.33% | [1 Missing :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |
| [...n/java/com/microsoft/sqlserver/jdbc/tdsparser.java](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft#diff-c3JjL21haW4vamF2YS9jb20vbWljcm9zb2Z0L3NxbHNlcnZlci9qZGJjL3Rkc3BhcnNlci5qYXZh) | 93.33% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft) |

<details><summary>Additional details and impacted files</summary>


```diff
@@              Coverage Diff              @@
##               main    #2251       +/-   ##
=============================================
- Coverage     73.56%   48.98%   -24.58%     
+ Complexity     6079     3709     -2370     
=============================================
  Files           143      144        +1     
  Lines         33179    33260       +81     
  Branches       5631     5636        +5     
=============================================
- Hits          24407    16293     -8114     
- Misses         6255    14604     +8349     
+ Partials       2517     2363      -154     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/microsoft/mssql-jdbc/pull/2251?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=microsoft).

goranschwarz: @lilgreenbird 
Just to be sure: I guess you have seen my answers to your "review comments"?
But you are busy with other stuff...

Patch:
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java b/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java
index 3a5db2bf..597bc962 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java
@@ -57,6 +57,7 @@ import java.util.Properties;
 import java.util.Set;
 import java.util.SimpleTimeZone;
 import java.util.TimeZone;
+import java.util.UUID;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.SynchronousQueue;
 import java.util.concurrent.ThreadPoolExecutor;
@@ -4784,6 +4785,32 @@ final class TDSWriter {
         writeBytes(val, 0, val.length);
     }
 
+    /**
+     * Append a UUID in RPC transmission format.
+     *
+     * @param sName
+     *        the optional parameter name
+     * @param uuidValue
+     *        the data value
+     * @param bOut
+     *        boolean true if the data value is being registered as an output parameter
+     */
+    void writeRPCUUID(String sName, UUID uuidValue, boolean bOut) throws SQLServerException {
+        writeRPCNameValType(sName, bOut, TDSType.GUID);
+
+        if (uuidValue == null) {
+            writeByte((byte) 0);
+            writeByte((byte) 0);
+
+        } else {
+            writeByte((byte) 0x10);  // maximum length = 16
+            writeByte((byte) 0x10);  // length = 16
+
+            byte[] val = Util.asGuidByteArray(uuidValue);
+            writeBytes(val, 0, val.length);
+        }
+    }
+
     /**
      * Appends a standard v*max header for RPC parameter transmission.
      * 
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerConnection.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerConnection.java
index 2f54a98a..a6054b1f 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerConnection.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerConnection.java
@@ -483,6 +483,19 @@ public interface ISQLServerConnection extends java.sql.Connection {
      */
     void setAccessTokenCallbackClass(String accessTokenCallbackClass);
 
+    /**
+     * Get Currently installed message handler on the connection
+     * @see {@link ISQLServerMessageHandler#messageHandler(ISQLServerMessage)}
+     * @return 
+     */
+    ISQLServerMessageHandler getServerMessageHandler();
+
+    /**
+     * Set message handler on the connection
+     * @see {@link ISQLServerMessageHandler#messageHandler(ISQLServerMessage)}
+     */
+    ISQLServerMessageHandler setServerMessageHandler(ISQLServerMessageHandler messageHandler);
+  
     /**
      * Returns the current flag for calcBigDecimalPrecision.
      *
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessage.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessage.java
new file mode 100644
index 00000000..055e812b
--- /dev/null
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessage.java
@@ -0,0 +1,93 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+package com.microsoft.sqlserver.jdbc;
+
+import java.sql.SQLException;
+
+public interface ISQLServerMessage
+{
+    /**
+     * Returns SQLServerError containing detailed info about SQL Server Message as received from SQL Server.
+     * 
+     * @return SQLServerError
+     */
+    public SQLServerError getSQLServerMessage();
+
+    /**
+     * Returns error message as received from SQL Server
+     * 
+     * @return Error Message
+     */
+    public String getErrorMessage();
+
+    /**
+     * Returns error number as received from SQL Server
+     * 
+     * @return Error Number
+     */
+    public int getErrorNumber();
+
+    /**
+     * Returns error state as received from SQL Server
+     * 
+     * @return Error State
+     */
+    public int getErrorState();
+
+    /**
+     * Returns Severity of error (as int value) as received from SQL Server
+     * 
+     * @return Error Severity
+     */
+    public int getErrorSeverity();
+
+    /**
+     * Returns name of the server where exception occurs as received from SQL Server
+     * 
+     * @return Server Name
+     */
+    public String getServerName();
+
+    /**
+     * Returns name of the stored procedure where exception occurs as received from SQL Server
+     * 
+     * @return Procedure Name
+     */
+    public String getProcedureName();
+
+    /**
+     * Returns line number where the error occurred in Stored Procedure returned by <code>getProcedureName()</code> as
+     * received from SQL Server
+     * 
+     * @return Line Number
+     */
+    public long getLineNumber();
+
+    /**
+     * Creates a SQLServerException or SQLServerWarning from this SQLServerMessage<br>
+     * @return
+     * <ul>
+     *   <li>SQLServerException if it's a SQLServerError object</li>
+     *   <li>SQLServerWarning if it's a SQLServerInfoMessage object</li>
+     * </ul>
+     */
+    public SQLException toSqlExceptionOrSqlWarning();
+
+    /**
+     * Check if this is a isErrorMessage
+     * @return true if it's an instance of SQLServerError
+     */
+    public default boolean isErrorMessage() {
+        return this instanceof SQLServerError;
+    }
+
+    /**
+     * Check if this is a SQLServerInfoMessage
+     * @return true if it's an instance of SQLServerInfoMessage
+     */
+    public default boolean isInfoMessage() {
+        return this instanceof SQLServerInfoMessage;
+    }
+}
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessageHandler.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessageHandler.java
new file mode 100644
index 00000000..74d7d751
--- /dev/null
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerMessageHandler.java
@@ -0,0 +1,103 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+package com.microsoft.sqlserver.jdbc;
+
+/**
+ * You can use the ISQLServerMessageHandler interface to customize the way JDBC handles error messages generated by the SQL Server. 
+ * Implementing ISQLServerMessageHandler in your own class for handling error messages can provide the following benefits:
+ * <ul>
+ *   <li><b>"message feedback"</b><br>
+ *       Display Server messages from a long running SQL Statement<br>
+ *       Like <code>RAISERROR ('Progress message...', 0, 1) WITH NOWAIT</code><br>
+ *       Or Status messages from a running backup...<br>
+ *   </li>
+ *   <li><b>"Universal" error logging</b><br>
+ *       Your error-message handler can contain the logic for handling all error logging.
+ *   </li>
+ *   <li><b>"Universal" error handling</b><br>
+ *       Error-handling logic can be placed in your error-message handler, instead of being repeated throughout your application.
+ *   </li>
+ *   <li><b>Remapping of error-message severity</b>, based on application requirements<br>
+ *       Your error-message handler can contain logic for recognizing specific error messages, and downgrading or upgrading 
+ *       their severity based on application considerations rather than the severity rating of the server. 
+ *       For example, during a cleanup operation that deletes old rows, you might want to downgrade the severity of a 
+ *       message that a row does not exist. However, you may want to upgrade the severity in other circumstances.
+ *   </li>
+ * </ul>
+ * <p>
+ * For example code, see {@link #messageHandler(ISQLServerMessage)}
+ */
+public interface ISQLServerMessageHandler
+{
+    /**
+     * You can use the ISQLServerMessageHandler interface to customize the way JDBC handles error messages generated by the SQL Server. 
+     * Implementing ISQLServerMessageHandler in your own class for handling error messages can provide the following benefits:
+     * <ul>
+     *   <li><b>"message feedback"</b><br>
+     *       Display Server messages from a long running SQL Statement<br>
+     *       Like <code>RAISERROR ('Progress message...', 0, 1) WITH NOWAIT</code><br>
+     *       Or Status messages from a running backup...<br>
+     *   </li>
+     *   <li><b>"Universal" error logging</b><br>
+     *       Your error-message handler can contain the logic for handling all error logging.
+     *   </li>
+     *   <li><b>"Universal" error handling</b><br>
+     *       Error-handling logic can be placed in your error-message handler, instead of being repeated throughout your application.
+     *   </li>
+     *   <li><b>Remapping of error-message severity</b>, based on application requirements<br>
+     *       Your error-message handler can contain logic for recognizing specific error messages, and downgrading or upgrading 
+     *       their severity based on application considerations rather than the severity rating of the server. 
+     *       For example, during a cleanup operation that deletes old rows, you might want to downgrade the severity of a 
+     *       message that a row does not exist. However, you may want to upgrade the severity in other circumstances.
+     *   </li>
+     * </ul>
+     * 
+     * Example code:
+     * <pre>
+     *  public ISQLServerMessage messageHandler(ISQLServerMessage serverErrorOrWarning)
+     *  {
+     *      ISQLServerMessage retObj = serverErrorOrWarning;
+     *
+     *      if (serverErrorOrWarning.isErrorMessage()) {
+     *
+     *          // Downgrade: 2601 -- Cannot insert duplicate key row...
+     *          if (2601 == serverErrorOrWarning.getErrorNumber()) {
+     *              retObj = serverErrorOrWarning.getSQLServerMessage().toSQLServerInfoMessage();
+     *          }
+     *
+     *          // Discard: 3701 -- Cannot drop the table ...
+     *          if (3701 == serverErrorOrWarning.getErrorNumber()) {
+     *              retObj = null;
+     *          }
+     *      }
+     *
+     *      return retObj;
+     *  }
+    
+     * </pre>
+     * 
+     * @param serverErrorOrWarning
+     * @return 
+     * <ul>
+     *   <li><b>unchanged</b> same object as passed in.<br>
+     *       The JDBC driver will works as if no message hander was installed<br>
+     *       Possibly used for logging functionality<br>
+     *   </li>
+     *   <li><b>null</b><br>
+     *       The JDBC driver will <i>discard</i> this message. No SQLException will be thrown
+     *   </li>
+     *   <li><b>SQLServerInfoMessage</b> object<br>
+     *       Create a "SQL warning" from a input database error, and return it. 
+     *       This results in the warning being added to the warning-message chain.
+     *   </li>
+     *   <li><b>SQLServerError</b> object<br>
+     *       If the originating message is a SQL warning (SQLServerInfoMessage object), messageHandler can evaluate 
+     *       the SQL warning as urgent and create and return a SQL exception (SQLServerError object)
+     *       to be thrown once control is returned to the JDBC Driver.
+     *   </li>
+     * </ul>
+     */
+    ISQLServerMessage messageHandler(ISQLServerMessage serverErrorOrWarning);
+}
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/Parameter.java b/src/main/java/com/microsoft/sqlserver/jdbc/Parameter.java
index 4894c45f..6b61cf53 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/Parameter.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/Parameter.java
@@ -22,6 +22,7 @@ import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.util.Calendar;
 import java.util.Locale;
+import java.util.UUID;
 
 
 /**
@@ -1125,6 +1126,10 @@ final class Parameter {
             setTypeDefinition(dtv);
         }
 
+        void execute(DTV dtv, UUID uuidValue) throws SQLServerException {
+            setTypeDefinition(dtv);
+        }
+
         void execute(DTV dtv, byte[] byteArrayValue) throws SQLServerException {
             // exclude JDBC typecasting for Geometry/Geography as these datatypes don't have a size limit.
             if (null != byteArrayValue && byteArrayValue.length > DataTypes.SHORT_VARTYPE_MAX_BYTES
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/PersistentTokenCacheAccessAspect.java b/src/main/java/com/microsoft/sqlserver/jdbc/PersistentTokenCacheAccessAspect.java
index 31b87fb2..e388ac90 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/PersistentTokenCacheAccessAspect.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/PersistentTokenCacheAccessAspect.java
@@ -22,13 +22,16 @@ import java.util.concurrent.locks.ReentrantLock;
  * @see <a href="https://aka.ms/msal4j-token-cache">https://aka.ms/msal4j-token-cache</a>
  */
 public class PersistentTokenCacheAccessAspect implements ITokenCacheAccessAspect {
-    private static PersistentTokenCacheAccessAspect instance = new PersistentTokenCacheAccessAspect();
-
+    private static PersistentTokenCacheAccessAspect instance;
     private final Lock lock = new ReentrantLock();
 
-    private PersistentTokenCacheAccessAspect() {}
+    static final long TIME_TO_LIVE = 86400000L; // Token cache time to live (24 hrs).
+    private long expiryTime;
 
     static PersistentTokenCacheAccessAspect getInstance() {
+        if (instance == null) {
+            instance = new PersistentTokenCacheAccessAspect();
+        }
         return instance;
     }
 
@@ -62,6 +65,14 @@ public class PersistentTokenCacheAccessAspect implements ITokenCacheAccessAspect
 
     }
 
+    public long getExpiryTime() {
+        return this.expiryTime;
+    }
+
+    public void setExpiryTime(long expiryTime) {
+        this.expiryTime = expiryTime;
+    }
+
     /**
      * Clears User token cache. This will clear all account info so interactive login will be required on the next
      * request to acquire an access token.
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java
index 982cf0e8..19f72cbc 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java
@@ -3509,8 +3509,7 @@ public class SQLServerConnection implements ISQLServerConnection, java.io.Serial
             // Whereas for dbMirroring, we sleep for every two attempts as each attempt is to a different server.
             // Make sure there's enough time to do another retry
             if (!isDBMirroring || (isDBMirroring && (0 == attemptNumber % 2))
-                    && (attemptNumber < connectRetryCount && connectRetryCount != 0) 
-                    && timerRemaining(
+                    && (attemptNumber < connectRetryCount && connectRetryCount != 0) && timerRemaining(
                             timerExpire) > (TimeUnit.SECONDS.toMillis(connectRetryInterval) + 2 * timeForFirstTry)) {
 
                 // don't wait for TNIR
@@ -4928,6 +4927,27 @@ public class SQLServerConnection implements ISQLServerConnection, java.io.Serial
         }
     }
 
+    // Any changes to SQLWarnings should be synchronized.
+    /** Used to add plain SQLWarning messages (if they do not hold extended information, like: ErrorSeverity, ServerName, ProcName etc */
+    void addWarning(SQLWarning sqlWarning) {
+        warningSynchronization.lock();
+        try {
+            if (null == sqlWarnings) {
+                sqlWarnings = sqlWarning;
+            } else {
+                sqlWarnings.setNextWarning(sqlWarning);
+            }
+        } finally {
+            warningSynchronization.unlock();
+        }
+    }
+
+    // Any changes to SQLWarnings should be synchronized.
+    /** Used to add messages that holds extended information, like: ErrorSeverity, ServerName, ProcName etc */
+    void addWarning(ISQLServerMessage sqlServerMessage) {
+        addWarning(new SQLServerWarning(sqlServerMessage.getSQLServerMessage()));
+    }
+
     @Override
     public void clearWarnings() throws SQLServerException {
         warningSynchronization.lock();
@@ -8500,6 +8520,34 @@ public class SQLServerConnection implements ISQLServerConnection, java.io.Serial
     public String getIPAddressPreference() {
         return activeConnectionProperties.getProperty(SQLServerDriverStringProperty.IPADDRESS_PREFERENCE.toString());
     }
+
+
+
+    /** Message handler */
+    private transient ISQLServerMessageHandler serverMessageHandler;
+    
+    /**
+     * Set current message handler
+     * 
+     * @param messageHandler
+     * @return The previously installed message handler (null if none)
+     */
+    @Override
+    public ISQLServerMessageHandler setServerMessageHandler(ISQLServerMessageHandler messageHandler)
+    {
+    	ISQLServerMessageHandler installedMessageHandler = this.serverMessageHandler;
+    	this.serverMessageHandler = messageHandler;
+        return installedMessageHandler;
+	}
+
+    /**
+     * @return Get Currently installed message handler on the connection
+     */
+    @Override
+    public ISQLServerMessageHandler getServerMessageHandler()
+    {
+        return this.serverMessageHandler;
+    }
 }
 
 
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionPoolProxy.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionPoolProxy.java
index ffda2dd6..5ed4b7b0 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionPoolProxy.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionPoolProxy.java
@@ -692,6 +692,16 @@ class SQLServerConnectionPoolProxy implements ISQLServerConnection, java.io.Seri
         wrappedConnection.setAccessTokenCallbackClass(accessTokenCallbackClass);
     }
 
+    @Override
+    public ISQLServerMessageHandler getServerMessageHandler() {
+        return wrappedConnection.getServerMessageHandler();
+    }
+    
+    @Override
+    public ISQLServerMessageHandler setServerMessageHandler(ISQLServerMessageHandler messageHandler) {
+    	return wrappedConnection.setServerMessageHandler(messageHandler);
+    }
+
     /**
      * Returns the current value for 'calcBigDecimalPrecision'.
      *
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerError.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerError.java
index 3454e5ae..1b4c1e83 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerError.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerError.java
@@ -6,12 +6,15 @@
 package com.microsoft.sqlserver.jdbc;
 
 import java.io.Serializable;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
 
 
 /**
  * SQLServerError represents a TDS error or message event.
  */
-public final class SQLServerError extends StreamPacket implements Serializable {
+public final class SQLServerError extends StreamPacket implements Serializable, ISQLServerMessage {
 
     /**
      * List SQL Server transient errors drivers will retry on from
@@ -221,6 +224,18 @@ public final class SQLServerError extends StreamPacket implements Serializable {
         super(TDS.TDS_ERR);
     }
 
+    SQLServerError(SQLServerError errorMsg) {
+        super(TDS.TDS_ERR);
+        this.errorNumber   = errorMsg.errorNumber;
+        this.errorState    = errorMsg.errorState;
+        this.errorSeverity = errorMsg.errorSeverity;
+        this.errorMessage  = errorMsg.errorMessage;
+        this.serverName    = errorMsg.serverName;
+        this.procName      = errorMsg.procName;
+        this.lineNumber    = errorMsg.lineNumber;
+    }
+    
+    @Override
     void setFromTDS(TDSReader tdsReader) throws SQLServerException {
         if (TDS.TDS_ERR != tdsReader.readUnsignedByte())
             assert false;
@@ -237,4 +252,104 @@ public final class SQLServerError extends StreamPacket implements Serializable {
         procName = tdsReader.readUnicodeString(tdsReader.readUnsignedByte());
         lineNumber = tdsReader.readUnsignedInt();
     }
+
+
+
+    /** 
+     * Holds any "overflow messages", or messages that has been added <b>after</b> the first message.
+     * <p>
+     * This is later on used when creating a SQLServerException.<br>
+     * Where all entries in the errorChain will be added {@link java.sql.SQLException#setNextException(SQLException)}
+     */
+    private List<SQLServerError> errorChain;
+    
+    void addError(SQLServerError sqlServerError) {
+        if (errorChain == null) {
+            errorChain = new ArrayList<>();
+        }
+        errorChain.add(sqlServerError);
+    }
+
+    List<SQLServerError> getErrorChain() {
+        return errorChain;
+    }
+    
+    @Override
+    public SQLServerError getSQLServerMessage()
+    {
+        return this;
+    }
+
+    /**
+     * Downgrade a Error message into a Info message
+     * <p>
+     * This simply create a SQLServerInfoMessage from this SQLServerError, 
+     * without changing the message content. 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerInfoMessage()
+    {
+        return toSQLServerInfoMessage(-1, -1);
+    }
+
+    /**
+     * Downgrade a Error message into a Info message
+     * <p>
+     * This simply create a SQLServerInfoMessage from this SQLServerError, 
+     * 
+     * @param newErrorSeverity  - The new ErrorSeverity
+     * 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerInfoMessage(int newErrorSeverity)
+    {
+        return toSQLServerInfoMessage(newErrorSeverity, -1);
+    }
+
+    /**
+     * Downgrade a Error message into a Info message
+     * <p>
+     * This simply create a SQLServerInfoMessage from this SQLServerError, 
+     * 
+     * @param newErrorSeverity  - If you want to change the ErrorSeverity (-1: leave unchanged)
+     * @param newErrorNumber    - If you want to change the ErrorNumber   (-1: leave unchanged)
+     * 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerInfoMessage(int newErrorSeverity, int newErrorNumber)
+    {
+        if (newErrorSeverity != -1) {
+            this.setErrorSeverity(newErrorSeverity);
+        }
+
+        if (newErrorNumber != -1) {
+            this.setErrorNumber(newErrorNumber);
+        }
+
+        return new SQLServerInfoMessage(this);
+    }
+
+    /**
+     * Set a new ErrorSeverity for this Message
+     * @param newSeverity
+     */
+    public void setErrorSeverity(int newSeverity)
+    {
+        this.errorSeverity = newSeverity;
+    }
+
+    /**
+     * Set a new ErrorNumber for this Message
+     * @param newSeverity
+     */
+    public void setErrorNumber(int newErrorNumber)
+    {
+        this.errorNumber = newErrorNumber;
+    }
+    
+    @Override
+    public SQLException toSqlExceptionOrSqlWarning()
+    {
+        return new SQLServerException(this);
+    }
 }
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerException.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerException.java
index 943804dc..77367708 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerException.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerException.java
@@ -7,6 +7,7 @@ package com.microsoft.sqlserver.jdbc;
 
 import java.sql.SQLFeatureNotSupportedException;
 import java.text.MessageFormat;
+import java.util.List;
 import java.util.UUID;
 import java.util.logging.Level;
 
@@ -178,6 +179,15 @@ public final class SQLServerException extends java.sql.SQLException {
         logException(obj, errText, bStack);
     }
 
+    public SQLServerException(SQLServerError sqlServerError) {
+        super(sqlServerError.getErrorMessage(), 
+            generateStateCode(null, sqlServerError.getErrorNumber(), sqlServerError.getErrorState()), 
+            sqlServerError.getErrorNumber(), 
+            null);
+
+        this.sqlServerError = sqlServerError;
+	}
+
     /**
      * Constructs a new SQLServerException.
      * 
@@ -261,6 +271,21 @@ public final class SQLServerException extends java.sql.SQLException {
                 SQLServerException.checkAndAppendClientConnId(errText, con), state, sqlServerError, bStack);
         theException.setDriverErrorCode(DRIVER_ERROR_FROM_DATABASE);
 
+        // Add any extra messages to the SQLException error chain
+        List<SQLServerError> errorChain = sqlServerError.getErrorChain();
+        if (errorChain != null) {
+            for (SQLServerError srvError : errorChain) {
+                String state2 = generateStateCode(con, srvError.getErrorNumber(), srvError.getErrorState());
+
+                SQLServerException chainException = new SQLServerException(obj,
+                        SQLServerException.checkAndAppendClientConnId(srvError.getErrorMessage(), con), 
+                        state2, srvError, bStack);
+                chainException.setDriverErrorCode(DRIVER_ERROR_FROM_DATABASE);
+                
+                theException.setNextException(chainException);
+            }
+        }
+
         // Close the connection if we get a severity 20 or higher error class (nClass is severity of error).
         if ((sqlServerError.getErrorSeverity() >= 20) && (null != con)) {
             con.notifyPooledConnection(theException);
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerInfoMessage.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerInfoMessage.java
new file mode 100644
index 00000000..8b657949
--- /dev/null
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerInfoMessage.java
@@ -0,0 +1,136 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+
+package com.microsoft.sqlserver.jdbc;
+
+import java.sql.SQLException;
+
+/**
+ * Holds information about SQL Server messages that is considered as Informational Messages (normally if SQL Server Severity is at 10)
+ * <p>
+ * Instead of just holding the SQL Server message (like a normal SQLWarning, it also holds all the 
+ * SQL Servers extended information, like: ErrorSeverity, ServerName, ProcName etc
+ * <p>
+ * This enables client to print out extra information about the message.<br>
+ * Like: In what procedure was the message produced.
+ * <p>
+ * A SQLServerInfoMessage is produced when reading the TDS Stream and added to the Connection as a SQLServerWarning
+ */
+public final class SQLServerInfoMessage extends StreamPacket implements ISQLServerMessage {
+    SQLServerError msg = new SQLServerError();
+
+    SQLServerInfoMessage() {
+        super(TDS.TDS_MSG);
+    }
+
+    SQLServerInfoMessage(SQLServerError msg) {
+        super(TDS.TDS_MSG);
+        this.msg = msg;
+    }
+
+    @Override
+    void setFromTDS(TDSReader tdsReader) throws SQLServerException {
+        if (TDS.TDS_MSG != tdsReader.readUnsignedByte())
+            assert false;
+        msg.setContentsFromTDS(tdsReader);
+    }
+
+    @Override
+    public SQLServerError getSQLServerMessage() {
+        return msg;
+    }
+
+    @Override
+    public String getErrorMessage() {
+        return msg.getErrorMessage();
+    }
+    
+    @Override
+    public int getErrorNumber() {
+        return msg.getErrorNumber();
+    }
+    
+    @Override
+    public int getErrorState() {
+        return msg.getErrorState();
+    }
+    
+    @Override
+    public int getErrorSeverity() {
+        return msg.getErrorSeverity();
+    }
+    
+    @Override
+    public String getServerName() {
+        return msg.getServerName();
+    }
+    
+    @Override
+    public String getProcedureName()
+    {
+        return msg.getProcedureName();
+    }
+    
+    @Override
+    public long getLineNumber()
+    {
+        return msg.getLineNumber();
+    }
+
+    /**
+     * Upgrade a Info message into a Error message
+     * <p>
+     * This simply create a SQLServerError from this SQLServerInfoMessage, 
+     * without changing the message content. 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerError()
+    {
+        return toSQLServerError(-1, -1);
+    }
+
+    /**
+     * Upgrade a Info message into a Error message
+     * <p>
+     * This simply create a SQLServerError from this SQLServerInfoMessage. 
+     * 
+     * @param newErrorSeverity  - The new ErrorSeverity
+     * 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerError(int newErrorSeverity)
+    {
+        return toSQLServerError(newErrorSeverity, -1);
+    }
+
+    /**
+     * Upgrade a Info message into a Error message
+     * <p>
+     * This simply create a SQLServerError from this SQLServerInfoMessage. 
+     * 
+     * @param newErrorSeverity  - If you want to change the ErrorSeverity (-1: leave unchanged)
+     * @param newErrorNumber    - If you want to change the ErrorNumber   (-1: leave unchanged)
+     * 
+     * @return
+     */
+    public ISQLServerMessage toSQLServerError(int newErrorSeverity, int newErrorNumber)
+    {
+        if (newErrorSeverity != -1) {
+            this.msg.setErrorSeverity(newErrorSeverity);
+        }
+
+        if (newErrorNumber != -1) {
+            this.msg.setErrorNumber(newErrorNumber);
+        }
+
+        return new SQLServerError(this.msg);
+    }
+
+    @Override
+    public SQLException toSqlExceptionOrSqlWarning()
+    {
+        return new SQLServerWarning(this.msg);
+    }
+}
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerMSAL4JUtils.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerMSAL4JUtils.java
index c8f2fb34..1ae69891 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerMSAL4JUtils.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerMSAL4JUtils.java
@@ -14,6 +14,7 @@ import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URISyntaxException;
 
+import java.security.MessageDigest;
 import java.text.MessageFormat;
 
 import java.util.Collections;
@@ -21,6 +22,7 @@ import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -61,6 +63,8 @@ class SQLServerMSAL4JUtils {
     static final String SLASH_DEFAULT = "/.default";
     static final String ACCESS_TOKEN_EXPIRE = "access token expires: ";
 
+    private static final TokenCacheMap TOKEN_CACHE_MAP = new TokenCacheMap();
+
     private final static String LOGCONTEXT = "MSAL version "
             + com.microsoft.aad.msal4j.PublicClientApplication.class.getPackage().getImplementationVersion() + ": ";
 
@@ -84,10 +88,17 @@ class SQLServerMSAL4JUtils {
         lock.lock();
 
         try {
+            String hashedSecret = getHashedSecret(new String[] {fedAuthInfo.stsurl, user, password});
+            PersistentTokenCacheAccessAspect persistentTokenCacheAccessAspect = TOKEN_CACHE_MAP.getEntry(hashedSecret);
+
+            if (null == persistentTokenCacheAccessAspect) {
+                persistentTokenCacheAccessAspect = new PersistentTokenCacheAccessAspect();
+                TOKEN_CACHE_MAP.addEntry(hashedSecret, persistentTokenCacheAccessAspect);
+            }
+
             final PublicClientApplication pca = PublicClientApplication
                     .builder(ActiveDirectoryAuthentication.JDBC_FEDAUTH_CLIENT_ID).executorService(executorService)
-                    .setTokenCacheAccessAspect(PersistentTokenCacheAccessAspect.getInstance())
-                    .authority(fedAuthInfo.stsurl).build();
+                    .setTokenCacheAccessAspect(persistentTokenCacheAccessAspect).authority(fedAuthInfo.stsurl).build();
 
             final CompletableFuture<IAuthenticationResult> future = pca.acquireToken(UserNamePasswordParameters
                     .builder(Collections.singleton(fedAuthInfo.spn + SLASH_DEFAULT), user, password.toCharArray())
@@ -132,11 +143,19 @@ class SQLServerMSAL4JUtils {
         lock.lock();
 
         try {
+            String hashedSecret = getHashedSecret(
+                    new String[] {fedAuthInfo.stsurl, aadPrincipalID, aadPrincipalSecret});
+            PersistentTokenCacheAccessAspect persistentTokenCacheAccessAspect = TOKEN_CACHE_MAP.getEntry(hashedSecret);
+
+            if (null == persistentTokenCacheAccessAspect) {
+                persistentTokenCacheAccessAspect = new PersistentTokenCacheAccessAspect();
+                TOKEN_CACHE_MAP.addEntry(hashedSecret, persistentTokenCacheAccessAspect);
+            }
+
             IClientCredential credential = ClientCredentialFactory.createFromSecret(aadPrincipalSecret);
             ConfidentialClientApplication clientApplication = ConfidentialClientApplication
                     .builder(aadPrincipalID, credential).executorService(executorService)
-                    .setTokenCacheAccessAspect(PersistentTokenCacheAccessAspect.getInstance())
-                    .authority(fedAuthInfo.stsurl).build();
+                    .setTokenCacheAccessAspect(persistentTokenCacheAccessAspect).authority(fedAuthInfo.stsurl).build();
 
             final CompletableFuture<IAuthenticationResult> future = clientApplication
                     .acquireToken(ClientCredentialParameters.builder(scopes).build());
@@ -181,6 +200,15 @@ class SQLServerMSAL4JUtils {
         lock.lock();
 
         try {
+            String hashedSecret = getHashedSecret(new String[] {fedAuthInfo.stsurl, aadPrincipalID, certFile,
+                    certPassword, certKey, certKeyPassword});
+            PersistentTokenCacheAccessAspect persistentTokenCacheAccessAspect = TOKEN_CACHE_MAP.getEntry(hashedSecret);
+
+            if (null == persistentTokenCacheAccessAspect) {
+                persistentTokenCacheAccessAspect = new PersistentTokenCacheAccessAspect();
+                TOKEN_CACHE_MAP.addEntry(hashedSecret, persistentTokenCacheAccessAspect);
+            }
+
             ConfidentialClientApplication clientApplication = null;
 
             // check if cert is PKCS12 first
@@ -202,8 +230,7 @@ class SQLServerMSAL4JUtils {
 
                 IClientCredential credential = ClientCredentialFactory.createFromCertificate(is, certPassword);
                 clientApplication = ConfidentialClientApplication.builder(aadPrincipalID, credential)
-                        .executorService(executorService)
-                        .setTokenCacheAccessAspect(PersistentTokenCacheAccessAspect.getInstance())
+                        .executorService(executorService).setTokenCacheAccessAspect(persistentTokenCacheAccessAspect)
                         .authority(fedAuthInfo.stsurl).build();
             } catch (FileNotFoundException e) {
                 // re-throw if file not there no point to try another format
@@ -232,8 +259,7 @@ class SQLServerMSAL4JUtils {
 
                 IClientCredential credential = ClientCredentialFactory.createFromCertificate(privateKey, cert);
                 clientApplication = ConfidentialClientApplication.builder(aadPrincipalID, credential)
-                        .executorService(executorService)
-                        .setTokenCacheAccessAspect(PersistentTokenCacheAccessAspect.getInstance())
+                        .executorService(executorService).setTokenCacheAccessAspect(persistentTokenCacheAccessAspect)
                         .authority(fedAuthInfo.stsurl).build();
             }
 
@@ -449,4 +475,45 @@ class SQLServerMSAL4JUtils {
             return new SQLServerException(form.format(msgArgs), null, 0, correctedExecutionException);
         }
     }
+
+    private static String getHashedSecret(String[] secrets) throws SQLServerException {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            for (String secret : secrets) {
+                if (null != secret) {
+                    md.update(secret.getBytes(java.nio.charset.StandardCharsets.UTF_16LE));
+                }
+            }
+            return new String(md.digest());
+        } catch (NoSuchAlgorithmException e) {
+            throw new SQLServerException(SQLServerException.getErrString("R_NoSHA256Algorithm"), e);
+        }
+    }
+
+    private static class TokenCacheMap {
+        private ConcurrentHashMap<String, PersistentTokenCacheAccessAspect> tokenCacheMap = new ConcurrentHashMap<>();
+
+        PersistentTokenCacheAccessAspect getEntry(String key) {
+            PersistentTokenCacheAccessAspect persistentTokenCacheAccessAspect = tokenCacheMap.get(key);
+
+            if (null != persistentTokenCacheAccessAspect) {
+                if (System.currentTimeMillis() > persistentTokenCacheAccessAspect.getExpiryTime()) {
+                    tokenCacheMap.remove(key);
+
+                    persistentTokenCacheAccessAspect = new PersistentTokenCacheAccessAspect();
+                    persistentTokenCacheAccessAspect
+                            .setExpiryTime(System.currentTimeMillis() + PersistentTokenCacheAccessAspect.TIME_TO_LIVE);
+
+                    tokenCacheMap.put(key, persistentTokenCacheAccessAspect);
+                }
+            }
+
+            return persistentTokenCacheAccessAspect;
+        }
+
+        void addEntry(String key, PersistentTokenCacheAccessAspect value) {
+            value.setExpiryTime(System.currentTimeMillis() + PersistentTokenCacheAccessAspect.TIME_TO_LIVE);
+            tokenCacheMap.put(key, value);
+        }
+    }
 }
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java
index 7789b372..294a1aa8 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java
@@ -75,6 +75,8 @@ public class SQLServerPreparedStatement extends SQLServerStatement implements IS
 
     private boolean isCallEscapeSyntax;
 
+    private boolean isFourPartSyntax;
+
     /** Parameter positions in processed SQL statement text. */
     final int[] userSQLParamPositions;
 
@@ -144,6 +146,11 @@ public class SQLServerPreparedStatement extends SQLServerStatement implements IS
      */
     private static final Pattern execEscapePattern = Pattern.compile("^\\s*(?i)(?:exec|execute)\\b");
 
+    /**
+     * Regex for four part syntax
+     */
+    private static final Pattern fourPartSyntaxPattern = Pattern.compile("(.+)\\.(.+)\\.(.+)\\.(.+)");
+
     /** Returns the prepared statement SQL */
     @Override
     public String toString() {
@@ -271,6 +278,7 @@ public class SQLServerPreparedStatement extends SQLServerStatement implements IS
         userSQL = parsedSQL.processedSQL;
         isExecEscapeSyntax = isExecEscapeSyntax(sql);
         isCallEscapeSyntax = isCallEscapeSyntax(sql);
+        isFourPartSyntax = isFourPartSyntax(sql);
         userSQLParamPositions = parsedSQL.parameterPositions;
         initParams(userSQLParamPositions.length);
         useBulkCopyForBatchInsert = conn.getUseBulkCopyForBatchInsert();
@@ -1234,10 +1242,12 @@ public class SQLServerPreparedStatement extends SQLServerStatement implements IS
         // 2. There must be parameters
         // 3. Parameters must not be a TVP type
         // 4. Compliant CALL escape syntax
-        // If isExecEscapeSyntax is true, EXEC escape syntax is used then use prior behaviour to
-        // execute the procedure
+        // If isExecEscapeSyntax is true, EXEC escape syntax is used then use prior behaviour of
+        // wrapping call to execute the procedure
+        // If isFourPartSyntax is true, sproc is being executed against linked server, then
+        // use prior behaviour of wrapping call to execute procedure
         return (null != procedureName && paramCount != 0 && !isTVPType(params) && isCallEscapeSyntax
-                && !isExecEscapeSyntax);
+                && !isExecEscapeSyntax && !isFourPartSyntax);
     }
 
     /**
@@ -1265,6 +1275,10 @@ public class SQLServerPreparedStatement extends SQLServerStatement implements IS
         return callEscapePattern.matcher(sql).find();
     }
 
+    private boolean isFourPartSyntax(String sql) {
+        return fourPartSyntaxPattern.matcher(sql).find();
+    }
+
     /**
      * Executes sp_prepare to prepare a parameterized statement and sets the prepared statement handle
      *
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
index 866b9ea1..cbfe102c 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java
@@ -1620,7 +1620,6 @@ public class SQLServerStatement implements ISQLServerStatement {
                         }
                     }
                 }
-
                 // If the current command (whatever it was) produced an error then stop parsing and propagate it up.
                 // In this case, the command is likely to be a RAISERROR, but it could be anything.
                 if (doneToken.isError())
@@ -1690,8 +1689,8 @@ public class SQLServerStatement implements ISQLServerStatement {
 
             @Override
             boolean onInfo(TDSReader tdsReader) throws SQLServerException {
-                StreamInfo infoToken = new StreamInfo();
-                infoToken.setFromTDS(tdsReader);
+                SQLServerInfoMessage infoMessage = new SQLServerInfoMessage();
+                infoMessage.setFromTDS(tdsReader);
 
                 // Under some circumstances the server cannot produce the cursored result set
                 // that we requested, but produces a client-side (default) result set instead.
@@ -1705,13 +1704,40 @@ public class SQLServerStatement implements ISQLServerStatement {
                 // ErrorCause: Server cursor is not supported on the specified SQL, falling back to default result set
                 // ErrorCorrectiveAction: None required
                 //
-                if (16954 == infoToken.msg.getErrorNumber())
+                if (16954 == infoMessage.msg.getErrorNumber())
                     executedSqlDirectly = true;
 
-                SQLWarning warning = new SQLWarning(
-                        infoToken.msg.getErrorMessage(), SQLServerException.generateStateCode(connection,
-                                infoToken.msg.getErrorNumber(), infoToken.msg.getErrorState()),
-                        infoToken.msg.getErrorNumber());
+
+                // Call the message handler to see what that think of the message
+                // - discard
+                // - upgrade to Error
+                // - or simply pass on
+                ISQLServerMessageHandler msgHandler = ((ISQLServerConnection)getConnection()).getServerMessageHandler();
+                if (msgHandler != null) {
+
+                    // Let the message handler decide if the error should be unchanged, up/down-graded or ignored
+                    ISQLServerMessage srvMessage = msgHandler.messageHandler(infoMessage);
+                
+                    // Ignored
+                    if (srvMessage == null) {
+                    	return true;
+                    }
+                
+                    // The message handler changed it to an "Error Message"
+                    if (srvMessage.isErrorMessage()) {
+                        // Set/Add the error message to the "super"
+                        addDatabaseError( (SQLServerError)srvMessage );
+                        return true;
+                    }
+                    
+                    // Still a "info message", just set infoMessage and the code in the below section will create the Warnings
+                    if (srvMessage.isInfoMessage()) {
+                        infoMessage = (SQLServerInfoMessage)srvMessage;
+                    }
+                }
+
+                // Create the SQLWarning and add them to the Warning chain
+                SQLWarning warning = new SQLServerWarning(infoMessage.msg);
 
                 if (sqlWarnings == null) {
                     sqlWarnings = new Vector<>();
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerWarning.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerWarning.java
new file mode 100644
index 00000000..81d588bd
--- /dev/null
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerWarning.java
@@ -0,0 +1,47 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+package com.microsoft.sqlserver.jdbc;
+
+import java.sql.SQLWarning;
+
+/**
+ * Holds information about SQL Server messages that is considered as Informational Messages (normally if SQL Server Severity is at 10)
+ * <p>
+ * Instead of just holding the SQL Server message (like a normal SQLWarning, it also holds all the 
+ * SQL Servers extended information, like: ErrorSeverity, ServerName, ProcName etc
+ * <p>
+ * This enables client to print out extra information about the message.<br>
+ * Like: In what procedure was the message produced.
+ */
+public class SQLServerWarning
+extends SQLWarning
+{
+    private static final long serialVersionUID = -5212432397705929142L;
+
+    /** SQL server error */
+    private SQLServerError sqlServerError;
+    
+    /*
+     * Create a SQLWarning from an SQLServerError object
+     */
+    public SQLServerWarning(SQLServerError sqlServerError) {
+        super(sqlServerError.getErrorMessage(), 
+            SQLServerException.generateStateCode(null, sqlServerError.getErrorNumber(), sqlServerError.getErrorState()), 
+            sqlServerError.getErrorNumber(), 
+            null);
+
+        this.sqlServerError = sqlServerError;
+    }
+
+    /**
+     * Returns SQLServerError object containing detailed info about exception as received from SQL Server. This API
+     * returns null if no server error has occurred.
+     * 
+     * @return SQLServerError
+     */
+    public SQLServerError getSQLServerError() {
+        return sqlServerError;
+    }
+}
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerXAResource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerXAResource.java
index 6d82bf83..eb96becb 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerXAResource.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerXAResource.java
@@ -5,7 +5,6 @@
 
 package com.microsoft.sqlserver.jdbc;
 
-import java.net.SocketException;
 import java.sql.CallableStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
@@ -25,7 +24,6 @@ import javax.transaction.xa.XAException;
 import javax.transaction.xa.XAResource;
 import javax.transaction.xa.Xid;
 
-import com.microsoft.sqlserver.jdbc.SQLServerError.TransientError;
 
 
 /**
@@ -754,27 +752,13 @@ public final class SQLServerXAResource implements javax.transaction.xa.XAResourc
                     }
                 }
             }
-        } catch (SQLTimeoutException ex) {
+        } catch (SQLTimeoutException | SQLServerException ex) {
             if (xaLogger.isLoggable(Level.FINER))
                 xaLogger.finer(toString() + " exception:" + ex);
             XAException e = new XAException(ex.toString());
             e.errorCode = XAException.XAER_RMFAIL;
             throw e;
 
-        } catch (SQLServerException ex) {
-            if (xaLogger.isLoggable(Level.FINER))
-                xaLogger.finer(toString() + " exception:" + ex);
-
-            if (ex.getMessage().equals(SQLServerException.getErrString("R_noServerResponse"))
-                    || TransientError.isTransientError(ex.getSQLServerError()) || isResourceManagerFailure(ex)) {
-                XAException e = new XAException(ex.toString());
-                e.errorCode = XAException.XAER_RMFAIL;
-                throw e;
-            }
-
-            XAException e = new XAException(ex.toString());
-            e.errorCode = XAException.XAER_RMERR;
-            throw e;
         }
 
         if (xaLogger.isLoggable(Level.FINER))
@@ -945,68 +929,4 @@ public final class SQLServerXAResource implements javax.transaction.xa.XAResourc
         return baseResourceID.incrementAndGet();
     }
 
-    private enum ResourceManagerFailure {
-        CONN_RESET("Connection reset"),
-        CONN_RESET_BY_PEER("Connection reset by peer"),
-        CONN_TIMEOUT("Connection timed out"),
-        CONN_RESILIENCY_CLIENT_UNRECOVERABLE(SQLServerException.getErrString("R_crClientUnrecoverable"));
-
-        private final String errString;
-
-        ResourceManagerFailure(String errString) {
-            this.errString = errString;
-        }
-
-        @Override
-        public String toString() {
-            return errString;
-        }
-
-        static ResourceManagerFailure fromString(String errString) {
-            for (ResourceManagerFailure resourceManagerFailure : ResourceManagerFailure.values()) {
-                if (errString.equalsIgnoreCase(resourceManagerFailure.toString())) {
-                    return resourceManagerFailure;
-                }
-            }
-            return null;
-        }
-    }
-
-    /**
-     * Check if the root exception of the throwable should be a XAER_RMFAIL exception
-     *
-     * @param throwable
-     *        The exception to check if the root cause should be a XAER_RMFAIL
-     *
-     * @return True if XAER_RMFAIL, otherwise false
-     */
-    private boolean isResourceManagerFailure(Throwable throwable) {
-        Throwable root = Util.getRootCause(throwable);
-
-        if (null == root) {
-            return false;
-        }
-
-        if (xaLogger.isLoggable(Level.FINE)) {
-            xaLogger.fine(toString() + " Resource manager failure root exception: " + root);
-        }
-
-        ResourceManagerFailure err = ResourceManagerFailure.fromString(root.getMessage());
-
-        if (null == err) {
-            return false;
-        }
-
-        // Add as needed here for future XAER_RMFAIL exceptions
-        switch (err) {
-            case CONN_RESET:
-            case CONN_RESET_BY_PEER:
-            case CONN_TIMEOUT:
-            case CONN_RESILIENCY_CLIENT_UNRECOVERABLE:
-                return true;
-            default:
-                return false;
-        }
-    }
-
 }
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/StreamInfo.java b/src/main/java/com/microsoft/sqlserver/jdbc/StreamInfo.java
deleted file mode 100644
index 5b4cb571..00000000
--- a/src/main/java/com/microsoft/sqlserver/jdbc/StreamInfo.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
- * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
- */
-
-package com.microsoft.sqlserver.jdbc;
-
-final class StreamInfo extends StreamPacket {
-    final SQLServerError msg = new SQLServerError();
-
-    StreamInfo() {
-        super(TDS.TDS_MSG);
-    }
-
-    void setFromTDS(TDSReader tdsReader) throws SQLServerException {
-        if (TDS.TDS_MSG != tdsReader.readUnsignedByte())
-            assert false;
-        msg.setContentsFromTDS(tdsReader);
-    }
-}
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/Util.java b/src/main/java/com/microsoft/sqlserver/jdbc/Util.java
index 70b9c9aa..61bda22b 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/Util.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/Util.java
@@ -13,8 +13,6 @@ import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.text.DecimalFormat;
 import java.text.MessageFormat;
-import java.util.List;
-import java.util.ArrayList;
 import java.util.Date;
 import java.util.Locale;
 import java.util.Properties;
@@ -1046,23 +1044,6 @@ final class Util {
         }
         return chars;
     }
-
-    /**
-     * @param throwable
-     *        The exception to find root cause for.
-     *
-     * @return The root cause of the exception, otherwise null if null throwable input
-     */
-    static Throwable getRootCause(Throwable throwable) {
-        final List<Throwable> list = new ArrayList<>();
-
-        while (throwable != null && !list.contains(throwable)) {
-            list.add(throwable);
-            throwable = throwable.getCause();
-        }
-
-        return list.isEmpty() ? null : list.get(list.size() - 1);
-    }
 }
 
 
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/dtv.java b/src/main/java/com/microsoft/sqlserver/jdbc/dtv.java
index c03d67eb..32a1c762 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/dtv.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/dtv.java
@@ -99,6 +99,8 @@ abstract class DTVExecuteOp {
 
     abstract void execute(DTV dtv, Boolean booleanValue) throws SQLServerException;
 
+    abstract void execute(DTV dtv, UUID uuidValue) throws SQLServerException;
+
     abstract void execute(DTV dtv, byte[] byteArrayValue) throws SQLServerException;
 
     abstract void execute(DTV dtv, Blob blobValue) throws SQLServerException;
@@ -291,7 +293,11 @@ final class DTV {
         }
 
         void execute(DTV dtv, String strValue) throws SQLServerException {
-            tdsWriter.writeRPCStringUnicode(name, strValue, isOutParam, collation, dtv.isNonPLP);
+            if (dtv.getJdbcType() == JDBCType.GUID) {
+                tdsWriter.writeRPCUUID(name, UUID.fromString(strValue), isOutParam);
+            } else {
+                tdsWriter.writeRPCStringUnicode(name, strValue, isOutParam, collation, dtv.isNonPLP);
+            }
         }
 
         void execute(DTV dtv, Clob clobValue) throws SQLServerException {
@@ -1126,6 +1132,10 @@ final class DTV {
             tdsWriter.writeRPCBit(name, booleanValue, isOutParam);
         }
 
+        void execute(DTV dtv, UUID uuidValue) throws SQLServerException {
+            tdsWriter.writeRPCUUID(name, uuidValue, isOutParam);
+        }
+
         void execute(DTV dtv, byte[] byteArrayValue) throws SQLServerException {
             if (null != cryptoMeta) {
                 tdsWriter.writeRPCNameValType(name, isOutParam, TDSType.BIGVARBINARY);
@@ -1537,10 +1547,13 @@ final class DTV {
                 case VARCHAR:
                 case LONGVARCHAR:
                 case CLOB:
-                case GUID:
                     op.execute(this, (byte[]) null);
                     break;
 
+                case GUID:
+                    op.execute(this, (UUID) null);
+                    break;
+
                 case TINYINT:
                     if (null != cryptoMeta)
                         op.execute(this, (byte[]) null);
@@ -1619,7 +1632,7 @@ final class DTV {
                             byte[] bArray = Util.asGuidByteArray((UUID) value);
                             op.execute(this, bArray);
                         } else {
-                            op.execute(this, String.valueOf(value));
+                            op.execute(this, UUID.fromString(String.valueOf(value)));
                         }
                     } else if (JDBCType.SQL_VARIANT == jdbcType) {
                         op.execute(this, String.valueOf(value));
@@ -2194,6 +2207,8 @@ final class AppDTVImpl extends DTVImpl {
 
         void execute(DTV dtv, Boolean booleanValue) throws SQLServerException {}
 
+        void execute(DTV dtv, UUID uuidValue) throws SQLServerException {}
+
         void execute(DTV dtv, byte[] byteArrayValue) throws SQLServerException {}
 
         void execute(DTV dtv, Blob blobValue) throws SQLServerException {
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java b/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
index c18d61a0..90eebb8d 100644
--- a/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
+++ b/src/main/java/com/microsoft/sqlserver/jdbc/tdsparser.java
@@ -197,6 +197,14 @@ class TDSTokenHandler {
         return databaseError;
     }
 
+    public void addDatabaseError(SQLServerError databaseError) {
+        if (this.databaseError == null) {
+            this.databaseError = databaseError;
+        } else {
+            this.databaseError.addError(databaseError);
+        }
+    }
+
     TDSTokenHandler(String logContext) {
         this.logContext = logContext;
     }
@@ -258,13 +266,29 @@ class TDSTokenHandler {
     }
 
     boolean onError(TDSReader tdsReader) throws SQLServerException {
-        if (null == databaseError) {
-            databaseError = new SQLServerError();
-            databaseError.setFromTDS(tdsReader);
-        } else {
-            (new SQLServerError()).setFromTDS(tdsReader);
+        SQLServerError tmpDatabaseError = new SQLServerError();
+        tmpDatabaseError.setFromTDS(tdsReader);
+
+        ISQLServerMessageHandler msgHandler = tdsReader.getConnection().getServerMessageHandler();
+        if (msgHandler != null) {
+            // Let the message handler decide if the error should be unchanged/down-graded or ignored
+            ISQLServerMessage srvMessage = msgHandler.messageHandler(tmpDatabaseError);
+
+            // Ignored
+            if (srvMessage == null) {
+                return true;
+            }
+
+            // Down-graded to a SQLWarning
+            if (srvMessage.isInfoMessage()) {
+                tdsReader.getConnection().addWarning(srvMessage);
+                return true;
+            }
         }
 
+        // set/add the database error
+        addDatabaseError(tmpDatabaseError);
+
         return true;
     }
 
diff --git a/src/main/java/microsoft/sql/DateTimeOffset.java b/src/main/java/microsoft/sql/DateTimeOffset.java
index e2fcc840..bf9e95c7 100644
--- a/src/main/java/microsoft/sql/DateTimeOffset.java
+++ b/src/main/java/microsoft/sql/DateTimeOffset.java
@@ -72,6 +72,22 @@ public final class DateTimeOffset implements java.io.Serializable, java.lang.Com
         assert 0 == this.utcMillis % 1000L : "utcMillis: " + this.utcMillis;
     }
 
+    /**
+     * Constructs a DateTimeOffset from an existing java.time.OffsetDateTime
+     * DateTimeOffset represents values to 100 nanosecond precision. If the java.time.OffsetDateTime instance
+     * represents a value that is more precise, the value is rounded to the nearest multiple of 100 nanoseconds. Values
+     * within 50 nanoseconds of the next second are rounded up to the next second.
+     *
+     * @param offsetDateTime
+     *        A java.time.OffsetDateTime value
+     */
+    private DateTimeOffset(java.time.OffsetDateTime offsetDateTime) {
+        int hundredNanos = ((offsetDateTime.getNano() + 50) / 100);
+        this.utcMillis = (offsetDateTime.toEpochSecond() * 1000) + (hundredNanos / HUNDRED_NANOS_PER_SECOND * 1000);
+        this.nanos = 100 * (hundredNanos % HUNDRED_NANOS_PER_SECOND);
+        this.minutesOffset = offsetDateTime.getOffset().getTotalSeconds() / 60;
+    }
+
     /**
      * Converts a java.sql.Timestamp value with an integer offset to the equivalent DateTimeOffset value
      * 
@@ -105,6 +121,20 @@ public final class DateTimeOffset implements java.io.Serializable, java.lang.Com
                 (calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET)) / (60 * 1000));
     }
 
+    /**
+     * Directly converts a {@link java.time.OffsetDateTime} value to an equivalent {@link DateTimeOffset} value
+     * DateTimeOffset represents values to 100 nanosecond precision. If the java.time.OffsetDateTime instance
+     * represents a value that is more precise, the value is rounded to the nearest multiple of 100 nanoseconds. Values
+     * within 50 nanoseconds of the next second are rounded up to the next second.
+     *
+     * @param offsetDateTime
+     *        A java.time.OffsetDateTime value
+     * @return The DateTimeOffset value of the input java.time.OffsetDateTime
+     */
+    public static DateTimeOffset valueOf(java.time.OffsetDateTime offsetDateTime) {
+        return new DateTimeOffset(offsetDateTime);
+    }
+
     /** formatted value */
     private String formattedValue = null;
 


Test patch:
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java
index 084a2ae9..12eea46c 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java
@@ -515,8 +515,8 @@ public class SQLServerConnectionTest extends AbstractTest {
         org.junit.Assume.assumeTrue(isWindows);
 
         // no retries no TNIR should fail even tho host is defined in host file
-        try (Connection con = PrepUtil.getConnection(
-                connectionString + ";transparentNetworkIPResolution=false;connectRetryCount=0;serverName=" + tnirHost);) {
+        try (Connection con = PrepUtil.getConnection(connectionString
+                + ";transparentNetworkIPResolution=false;connectRetryCount=0;serverName=" + tnirHost);) {
             assertTrue(con == null, TestResource.getResource("R_shouldNotConnect"));
         } catch (Exception e) {
             assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_tcpipConnectionFailed"))
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java b/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java
index 60725403..aa4252f9 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java
@@ -63,6 +63,11 @@ public final class TestResource extends ListResourceBundle {
             {"R_ConnectionURLNull", "The connection URL is null."},
             {"R_connectionIsNotClosed", "The connection is not closed."},
             {"R_invalidExceptionMessage", "Invalid exception message"},
+            {"R_invalidClientSecret", "AADSTS7000215: Invalid client secret provided"},
+            {"R_invalidCertFields",
+                    "Error reading certificate, please verify the location of the certificate.signed fields invalid"},
+            {"R_invalidAADAuth",
+                    "Failed to authenticate the user {0} in Active Directory (Authentication={1})"},
             {"R_failedValidate", "failed to validate values in $0} "}, {"R_tableNotDropped", "table not dropped. "},
             {"R_connectionReset", "Connection reset"}, {"R_unknownException", "Unknown exception"},
             {"R_deadConnection", "Dead connection should be invalid"},
@@ -213,5 +218,6 @@ public final class TestResource extends ListResourceBundle {
             {"R_noLoginModulesConfiguredForJdbcDriver",
                     "javax.security.auth.login.LoginException (No LoginModules configured for SQLJDBCDriver)"},
             {"R_unexpectedThreadCount", "Thread count is higher than expected."},
-            {"R_expectedClassDoesNotMatchActualClass", "Expected column class {0} does not match actual column class {1} for column {2}."}};
+            {"R_expectedClassDoesNotMatchActualClass",
+                    "Expected column class {0} does not match actual column class {1} for column {2}."}};
 }
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/callablestatement/CallableStatementTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/callablestatement/CallableStatementTest.java
index 6570abf7..51203939 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/callablestatement/CallableStatementTest.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/callablestatement/CallableStatementTest.java
@@ -1150,6 +1150,66 @@ public class CallableStatementTest extends AbstractTest {
         }
     }
 
+    @Test
+    @Tag(Constants.reqExternalSetup)
+    @Tag(Constants.xAzureSQLDB)
+    @Tag(Constants.xAzureSQLDW)
+    @Tag(Constants.xAzureSQLMI)
+    public void testFourPartSyntaxCallEscapeSyntax() throws SQLException {
+        String table = "serverList";
+
+        try (Statement stmt = connection.createStatement()) {
+            stmt.execute("IF OBJECT_ID(N'" + table + "') IS NOT NULL DROP TABLE " + table);
+            stmt.execute("CREATE TABLE " + table + " (serverName varchar(100),network varchar(100),serverStatus varchar(4000), id int, collation varchar(100), connectTimeout int, queryTimeout int)");
+            stmt.execute("INSERT " + table + " EXEC sp_helpserver");
+
+            ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + table + " WHERE serverName = N'" + linkedServer + "'");
+            rs.next();
+
+            if (rs.getInt(1) == 1) {
+                stmt.execute("EXEC sp_dropserver @server='" + linkedServer + "';");
+            }
+
+            stmt.execute("EXEC sp_addlinkedserver @server='" + linkedServer + "';");
+            stmt.execute("EXEC sp_addlinkedsrvlogin @rmtsrvname=N'" + linkedServer + "', @rmtuser=N'" + remoteUser + "', @rmtpassword=N'" + remotePassword + "'");
+            stmt.execute("EXEC sp_serveroption '" + linkedServer + "', 'rpc', true;");
+            stmt.execute("EXEC sp_serveroption '" + linkedServer + "', 'rpc out', true;");
+        }
+
+        SQLServerDataSource ds = new SQLServerDataSource();
+        ds.setServerName(linkedServer);
+        ds.setUser(remoteUser);
+        ds.setPassword(remotePassword);
+        ds.setEncrypt(false);
+        ds.setTrustServerCertificate(true);
+
+        try (Connection linkedServerConnection = ds.getConnection(); Statement stmt = linkedServerConnection.createStatement()) {
+            stmt.execute("create or alter procedure dbo.TestAdd(@Num1 int, @Num2 int, @Result int output) as begin set @Result = @Num1 + @Num2; end;");
+        }
+
+        try (CallableStatement cstmt = connection.prepareCall("{call [" + linkedServer + "].master.dbo.TestAdd(?,?,?)}")) {
+            int sum = 11;
+            int param0 = 1;
+            int param1 = 10;
+            cstmt.setInt(1, param0);
+            cstmt.setInt(2, param1);
+            cstmt.registerOutParameter(3, Types.INTEGER);
+            cstmt.execute();
+            assertEquals(sum, cstmt.getInt(3));
+        }
+
+        try (CallableStatement cstmt = connection.prepareCall("exec [" + linkedServer + "].master.dbo.TestAdd ?,?,?")) {
+            int sum = 11;
+            int param0 = 1;
+            int param1 = 10;
+            cstmt.setInt(1, param0);
+            cstmt.setInt(2, param1);
+            cstmt.registerOutParameter(3, Types.INTEGER);
+            cstmt.execute();
+            assertEquals(sum, cstmt.getInt(3));
+        }
+    }
+
     /**
      * Cleanup after test
      *
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java
index d758950f..926b6a76 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java
@@ -521,6 +521,10 @@ public class RequestBoundaryMethodsTest extends AbstractTest {
         verifiedMethodNames.add("setMsiTokenCacheTtl");
         verifiedMethodNames.add("getAccessTokenCallbackClass");
         verifiedMethodNames.add("setAccessTokenCallbackClass");
+        verifiedMethodNames.add("getServerMessageHandler");
+        verifiedMethodNames.add("setServerMessageHandler");
+        verifiedMethodNames.add("getCalcBigDecimalScale");
+        verifiedMethodNames.add("setCalcBigDecimalScale");
         verifiedMethodNames.add("getUseFlexibleCallableStatements");
         verifiedMethodNames.add("setUseFlexibleCallableStatements");
         verifiedMethodNames.add("getCalcBigDecimalPrecision");
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/DataTypesTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/DataTypesTest.java
index 350f7cfc..445d23dd 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/DataTypesTest.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/DataTypesTest.java
@@ -1935,6 +1935,17 @@ public class DataTypesTest extends AbstractTest {
         }
     }
 
+    @Test
+    public void testDateTimeOffsetValueOfOffsetDateTime() throws Exception {
+        OffsetDateTime expected = OffsetDateTime.now().withSecond(58).withNano(0);
+        OffsetDateTime roundUp = expected.withSecond(57).withNano(999999950);
+        OffsetDateTime roundDown = expected.withSecond(58).withNano(49);
+
+        assertEquals(expected, DateTimeOffset.valueOf(expected).getOffsetDateTime());
+        assertEquals(expected, DateTimeOffset.valueOf(roundUp).getOffsetDateTime());
+        assertEquals(expected, DateTimeOffset.valueOf(roundDown).getOffsetDateTime());
+    }
+
     static LocalDateTime getUnstorableValue() throws Exception {
         ZoneId systemTimezone = ZoneId.systemDefault();
         Instant now = Instant.now();
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/GuidTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/GuidTest.java
new file mode 100644
index 00000000..e8b5c157
--- /dev/null
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/datatypes/GuidTest.java
@@ -0,0 +1,97 @@
+package com.microsoft.sqlserver.jdbc.datatypes;
+
+import com.microsoft.sqlserver.jdbc.RandomUtil;
+import com.microsoft.sqlserver.jdbc.SQLServerResultSet;
+import com.microsoft.sqlserver.jdbc.TestResource;
+import com.microsoft.sqlserver.jdbc.TestUtils;
+import com.microsoft.sqlserver.testframework.AbstractSQLGenerator;
+import com.microsoft.sqlserver.testframework.AbstractTest;
+import microsoft.sql.Types;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.UUID;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*
+ * This test is for testing the serialisation of String as microsoft.sql.Types.GUID
+ */
+@RunWith(JUnitPlatform.class)
+public class GuidTest extends AbstractTest {
+
+    final static String tableName = RandomUtil.getIdentifier("GuidTestTable");
+    final static String escapedTableName = AbstractSQLGenerator.escapeIdentifier(tableName);
+
+    @BeforeAll
+    public static void setupTests() throws Exception {
+        setConnection();
+    }
+
+    /*
+     * Test UUID conversions
+     */
+    @Test
+    public void testGuid() throws Exception {
+        try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) {
+
+            // Create the test table
+            TestUtils.dropTableIfExists(escapedTableName, stmt);
+
+            String query = "create table " + escapedTableName
+                    + " (uuid uniqueidentifier, id int IDENTITY primary key)";
+            stmt.executeUpdate(query);
+
+            UUID uuid = UUID.randomUUID();
+            String uuidString = uuid.toString();
+            int id = 1;
+
+            try (PreparedStatement pstmt = conn.prepareStatement("INSERT INTO " + escapedTableName
+                    + " VALUES(?) SELECT * FROM " + escapedTableName + " where id = ?")) {
+
+                pstmt.setObject(1, uuidString, Types.GUID);
+                pstmt.setObject(2, id++);
+                pstmt.execute();
+                pstmt.getMoreResults();
+                try (SQLServerResultSet rs = (SQLServerResultSet) pstmt.getResultSet()) {
+                    rs.next();
+                    assertEquals(uuid, UUID.fromString(rs.getUniqueIdentifier(1)));
+                }
+
+                // Test NULL GUID
+                pstmt.setObject(1, null, Types.GUID);
+                pstmt.setObject(2, id++);
+                pstmt.execute();
+                pstmt.getMoreResults();
+                try (SQLServerResultSet rs = (SQLServerResultSet) pstmt.getResultSet()) {
+                    rs.next();
+                    String s = rs.getUniqueIdentifier(1);
+                    assertNull(s);
+                    assertTrue(rs.wasNull());
+                }
+
+                // Test Illegal GUID
+                try {
+                    pstmt.setObject(1, "garbage", Types.GUID);
+                    fail(TestResource.getResource("R_expectedFailPassed"));
+                } catch (IllegalArgumentException e) {
+                    assertEquals("Invalid UUID string: garbage", e.getMessage());
+                }
+            }
+        } finally {
+            try (Statement stmt = connection.createStatement()) {
+                TestUtils.dropTableIfExists(escapedTableName, stmt);
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/exception/ChainedExceptionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/exception/ChainedExceptionTest.java
new file mode 100644
index 00000000..9e6b1d4b
--- /dev/null
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/exception/ChainedExceptionTest.java
@@ -0,0 +1,71 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+package com.microsoft.sqlserver.jdbc.exception;
+
+import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+
+import com.microsoft.sqlserver.jdbc.TestResource;
+import com.microsoft.sqlserver.testframework.AbstractTest;
+
+
+@RunWith(JUnitPlatform.class)
+public class ChainedExceptionTest extends AbstractTest {
+
+    @BeforeAll
+    public static void setupTests() throws Exception {
+        setConnection();
+    }
+
+    @Test
+    public void testTwoExceptions() throws Exception {
+        try (Connection conn = getConnection()) {
+
+            // The below should yield the following Server Messages:
+            //  1 : Msg 5074, Level 16, State 1: The object 'DF__#chained_exc__c1__AB25243A' is dependent on column 'c1'.
+            //  1 : Msg 4922, Level 16, State 9: ALTER TABLE ALTER COLUMN c1 failed because one or more objects access this column.
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate("CREATE TABLE #chained_exception_test_x1(c1 INT DEFAULT(0))"); 
+                stmnt.executeUpdate("ALTER  TABLE #chained_exception_test_x1 ALTER COLUMN c1 VARCHAR(10)"); 
+                stmnt.executeUpdate("DROP   TABLE IF EXISTS #chained_exception_test_x1"); 
+            }
+
+            fail(TestResource.getResource("R_expectedFailPassed"));
+
+        } catch (SQLException ex) {
+
+            // Check the SQLException and the chain
+            int exCount     = 0;
+            int firstMsgNum = ex.getErrorCode();
+            int lastMsgNum  = -1;
+
+            while (ex != null) {
+            	exCount++;
+
+                lastMsgNum = ex.getErrorCode();
+
+            	ex = ex.getNextException();
+            }
+            
+            // Exception Count should be: 2
+            assertEquals(2, exCount, "Number of SQLExceptions in the SQLException chain");
+
+            // Check first Msg: 5074
+            assertEquals(5074, firstMsgNum, "First SQL Server Message");
+
+            // Check last Msg: 4922
+            assertEquals(4922, lastMsgNum, "Last SQL Server Message");
+        }
+    }
+}
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/fedauth/FedauthTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/fedauth/FedauthTest.java
index 4bae199d..e68fcacd 100644
--- a/src/test/java/com/microsoft/sqlserver/jdbc/fedauth/FedauthTest.java
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/fedauth/FedauthTest.java
@@ -18,6 +18,7 @@ import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.text.MessageFormat;
 import java.util.Collections;
 import java.util.Properties;
 import java.util.concurrent.CompletableFuture;
@@ -323,6 +324,66 @@ public class FedauthTest extends FedauthCommon {
         }
     }
 
+    @Test
+    public void testAADServicePrincipalAuthFailureOnSubsequentConnectionsWithInvalidatedTokenCacheWithInvalidSecret() throws Exception {
+        String url = "jdbc:sqlserver://" + azureServer + ";database=" + azureDatabase + ";authentication="
+                + SqlAuthentication.ActiveDirectoryServicePrincipal + ";Username=" + applicationClientID + ";Password="
+                + applicationKey;
+
+        String invalidSecretUrl = "jdbc:sqlserver://" + azureServer + ";database=" + azureDatabase + ";authentication="
+                + SqlAuthentication.ActiveDirectoryServicePrincipal + ";Username=" + applicationClientID + ";Password="
+                + "invalidSecret";
+
+        // Should succeed on valid secret
+        try (Connection connection = DriverManager.getConnection(url)) {}
+
+        // Should fail on invalid secret
+        try (Connection connection = DriverManager.getConnection(invalidSecretUrl)) {
+            fail(TestResource.getResource("R_expectedFailPassed"));
+        } catch (Exception e) {
+            assertTrue(e.getMessage().contains(TestResource.getResource("R_invalidClientSecret")),
+                    "Expected R_invalidClientSecret error.");
+        }
+    }
+
+    @Test
+    public void testActiveDirectoryPasswordFailureOnSubsequentConnectionsWithInvalidatedTokenCacheWithInvalidPassword() throws Exception {
+
+        // Should succeed on valid password
+        try (Connection conn = DriverManager.getConnection(adPasswordConnectionStr)) {}
+
+        // Should fail on invalid password
+        try (Connection conn = DriverManager.getConnection(adPasswordConnectionStr + ";password=invalidPassword;")) {
+            fail(TestResource.getResource("R_expectedFailPassed"));
+        } catch (Exception e) {
+            MessageFormat form = new MessageFormat(TestResource.getResource("R_invalidAADAuth"));
+            Object[] msgArgs = {azureUserName, "ActiveDirectoryPassword"};
+            assertTrue(e.getMessage().contains(form.format(msgArgs)), "Expected R_invalidAADAuth error.");
+        }
+    }
+
+    @Test
+    public void testAADServicePrincipalCertAuthFailureOnSubsequentConnectionsWithInvalidatedTokenCacheWithInvalidPassword() throws Exception {
+        // Should succeed on valid cert field values
+        String url = "jdbc:sqlserver://" + azureServer + ";database=" + azureDatabase + ";authentication="
+                + SqlAuthentication.ActiveDirectoryServicePrincipalCertificate + ";Username=" + applicationClientID
+                + ";password=" + certificatePassword + ";clientCertificate=" + clientCertificate;
+
+        // Should fail on invalid cert field values
+        String invalidPasswordUrl = "jdbc:sqlserver://" + azureServer + ";database=" + azureDatabase
+                + ";authentication=" + SqlAuthentication.ActiveDirectoryServicePrincipalCertificate + ";Username="
+                + applicationClientID + ";password=invalidPassword;clientCertificate=" + clientCertificate;
+
+        try (Connection conn = DriverManager.getConnection(url)) {}
+
+        try (Connection conn = DriverManager.getConnection(invalidPasswordUrl)) {
+            fail(TestResource.getResource("R_expectedFailPassed"));
+        } catch (Exception e) {
+            assertTrue(e.getMessage().contains(TestResource.getResource("R_invalidCertFields")),
+                    "Expected R_invalidCertFields error.");
+        }
+    }
+
     /**
      * Test invalid connection property combinations when using AAD Service Principal Authentication.
      */
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/messageHandler/MessageHandlerTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/messageHandler/MessageHandlerTest.java
new file mode 100644
index 00000000..94042a7d
--- /dev/null
+++ b/src/test/java/com/microsoft/sqlserver/jdbc/messageHandler/MessageHandlerTest.java
@@ -0,0 +1,497 @@
+/*
+ * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made
+ * available under the terms of the MIT License. See the LICENSE file in the project root for more information.
+ */
+package com.microsoft.sqlserver.jdbc.messageHandler;
+
+import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.SQLWarning;
+import java.sql.Statement;
+import java.sql.Types;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+
+import com.microsoft.sqlserver.jdbc.ISQLServerMessage;
+import com.microsoft.sqlserver.jdbc.ISQLServerMessageHandler;
+import com.microsoft.sqlserver.jdbc.SQLServerConnection;
+import com.microsoft.sqlserver.jdbc.TestResource;
+import com.microsoft.sqlserver.testframework.AbstractTest;
+
+
+@RunWith(JUnitPlatform.class)
+public class MessageHandlerTest extends AbstractTest {
+
+    @BeforeAll
+    public static void setupTests() throws Exception {
+        setConnection();
+    }
+
+
+    /**
+     * Helper method to count number of SQLWarnings in a chain
+     * @param str   - Debug String, so we can evaluate from where we called it...
+     * @param sqlw  - The SQL Warning chain. (can be null)
+     * @return A count of warnings
+     */
+    private static int getWarningCount(String str, SQLWarning sqlw)
+    {
+        int count = 0;
+        while(sqlw != null) {
+            count++;
+            //System.out.println("DEBUG: getWarningCount(): [" + str + "] SQLWarning: Error=" + sqlw.getErrorCode() + ", Severity=" + ((SQLServerWarning)sqlw).getSQLServerError().getErrorSeverity() + ", Text=|" + sqlw.getMessage() + "|.");
+            sqlw = sqlw.getNextWarning();
+        }
+        return count;
+    }
+
+
+    /**
+     * Test message handler with normal Statement
+     * <ul>
+     *   <li> Insert duplicate row -- Mapped to Info Message</li>
+     *   <li> Drop table that do not exist -- Mapped to ignore </li>
+     * </ul>
+     */
+    @Test
+    public void testMsgHandlerWithStatement() throws Exception {
+        try (SQLServerConnection conn = getConnection()) {
+
+            class TestMsgHandler implements ISQLServerMessageHandler 
+            {
+                int numOfCalls      = 0;
+                int numOfDowngrades = 0;
+                int numOfDiscards   = 0;
+
+                @Override
+                public ISQLServerMessage messageHandler(ISQLServerMessage srvErrorOrWarning)
+                {
+                    numOfCalls++;
+                    ISQLServerMessage retObj = srvErrorOrWarning;
+
+                    if (srvErrorOrWarning.isErrorMessage()) {
+
+                        // Downgrade: 2601 -- Cannot insert duplicate key row in object 'dbo.#msghandler_tmp_table' with unique index 'ix_id'. The duplicate key value is (1)
+                        if (2601 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = srvErrorOrWarning.getSQLServerMessage().toSQLServerInfoMessage();
+                            numOfDowngrades++;
+                        }
+
+                        // Discard: 3701 -- Cannot drop the table '#msghandler_tmp_table', because it does not exist or you do not have permission.
+                        if (3701 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    if (srvErrorOrWarning.isInfoMessage()) {
+
+                        // Discard: 3621 -- The statement has been terminated.
+                        if (3621 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    return retObj;
+                }
+            }
+            TestMsgHandler testMsgHandler = new TestMsgHandler();
+
+            // Create a massage handler
+            conn.setServerMessageHandler(testMsgHandler); 
+
+            try (Statement stmnt = conn.createStatement()) { 
+
+                stmnt.executeUpdate("CREATE TABLE #msghandler_tmp_table(id int, c1 varchar(255))"); 
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'create table', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'create table', at Statement.");
+
+                stmnt.executeUpdate("CREATE UNIQUE INDEX ix_id ON #msghandler_tmp_table(id)"); 
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'create index', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'create index', at Statement.");
+
+                stmnt.executeUpdate("INSERT INTO #msghandler_tmp_table VALUES(1, 'row 1')"); 
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'first insert', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'first insert', at Statement.");
+
+                stmnt.executeUpdate("INSERT INTO #msghandler_tmp_table VALUES(1, 'row 1 - again - msg handler downgrades it')"); 
+                assertNotNull(conn .getWarnings(), "Expecting at least ONE SQLWarnings from 'second insert', which is a duplicate row, at Connection.");
+                assertNull   (stmnt.getWarnings(), "Expecting NO SQLWarnings from 'second insert', which is a duplicate row, at Statement.");
+                conn.clearWarnings(); // Clear Warnings at Connection level
+
+                stmnt.executeUpdate("DROP TABLE #msghandler_tmp_table"); 
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Statement.");
+
+                stmnt.executeUpdate("DROP TABLE #msghandler_tmp_table");  // This should be IGNORED by the message handler
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'second drop table, since it should be IGNORED by the message handler', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'second drop table, since it should be IGNORED by the message handler', at Statement.");
+
+                // numOfCalls to the message handler should be: 3
+                assertEquals(3, testMsgHandler.numOfCalls, "Number of message calls to the message handler.");
+
+                // numOfDowngrades in the message handler should be: 1
+                assertEquals(1, testMsgHandler.numOfDowngrades, "Number of message Downgrades in the message handler.");
+
+                // numOfDiscards in the message handler should be: 2
+                assertEquals(2, testMsgHandler.numOfDiscards, "Number of message Discards in the message handler.");
+            }
+
+        } catch (SQLException ex) {
+            fail(TestResource.getResource("R_unexpectedErrorMessage"));
+        }
+    }
+
+
+
+    /**
+     * Test message handler with PreparedStatement
+     * <ul>
+     *   <li> Insert duplicate row -- Mapped to Info Message</li>
+     *   <li> Drop table that do not exist -- Mapped to ignore </li>
+     * </ul>
+     */
+    @Test
+    public void testMsgHandlerWithPreparedStatement() throws Exception {
+        try (SQLServerConnection conn = getConnection()) {
+
+            class TestMsgHandler implements ISQLServerMessageHandler 
+            {
+                int numOfCalls      = 0;
+                int numOfDowngrades = 0;
+                int numOfDiscards   = 0;
+
+                @Override
+                public ISQLServerMessage messageHandler(ISQLServerMessage srvErrorOrWarning)
+                {
+                    numOfCalls++;
+                    ISQLServerMessage retObj = srvErrorOrWarning;
+
+                    if (srvErrorOrWarning.isErrorMessage()) {
+
+                        // Downgrade: 2601 -- Cannot insert duplicate key row in object 'dbo.#msghandler_tmp_table' with unique index 'ix_id'. The duplicate key value is (1)
+                        if (2601 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = srvErrorOrWarning.getSQLServerMessage().toSQLServerInfoMessage();
+                            numOfDowngrades++;
+                        }
+
+                        // Discard: 3701 -- Cannot drop the table '#msghandler_tmp_table', because it does not exist or you do not have permission.
+                        if (3701 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    if (srvErrorOrWarning.isInfoMessage()) {
+
+                        // Discard: 3621 -- The statement has been terminated.
+                        if (3621 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    return retObj;
+                }
+            }
+            TestMsgHandler testMsgHandler = new TestMsgHandler();
+
+            // Create a massage handler
+            conn.setServerMessageHandler(testMsgHandler); 
+
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate("CREATE TABLE #msghandler_tmp_table(id int, c1 varchar(255))");
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'create table', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'create table', at Statement.");
+            }
+
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate("CREATE UNIQUE INDEX ix_id ON #msghandler_tmp_table(id)");
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'create index', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'create index', at Statement.");
+            }
+
+            try (PreparedStatement stmnt = conn.prepareStatement("INSERT INTO #msghandler_tmp_table VALUES(?, ?)")) { 
+                stmnt.setInt(1, 1);
+                stmnt.setString(2, "row 1");
+                stmnt.executeUpdate();
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'first insert', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'first insert', at Statement.");
+            }
+
+            try (PreparedStatement stmnt = conn.prepareStatement("INSERT INTO #msghandler_tmp_table VALUES(?, ?)")) { 
+                stmnt.setInt(1, 1);
+                stmnt.setString(2, "row 1 - again - msg handler downgrades it");
+                stmnt.executeUpdate();
+                assertNotNull(conn .getWarnings(), "Expecting at least ONE SQLWarnings from 'second insert', which is a duplicate row, at Connection.");
+                assertNull   (stmnt.getWarnings(), "Expecting NO SQLWarnings from 'second insert', which is a duplicate row, at Statement.");
+                conn.clearWarnings(); // Clear Warnings at Connection level
+            }
+
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate("DROP TABLE #msghandler_tmp_table");
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Statement.");
+            }
+
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate("DROP TABLE #msghandler_tmp_table");
+                assertNull(conn .getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Connection.");
+                assertNull(stmnt.getWarnings(), "Expecting NO SQLWarnings from 'first drop table', at Statement.");
+            }
+
+
+            // numOfCalls to the message handler should be: 3
+            assertEquals(3, testMsgHandler.numOfCalls, "Number of message calls to the message handler.");
+
+            // numOfDowngrades in the message handler should be: 1
+            assertEquals(1, testMsgHandler.numOfDowngrades, "Number of message Downgrades in the message handler.");
+
+            // numOfDiscards in the message handler should be: 2
+            assertEquals(2, testMsgHandler.numOfDiscards, "Number of message Discards in the message handler.");
+
+        } catch (SQLException ex) {
+            fail(TestResource.getResource("R_unexpectedErrorMessage"));
+        }
+    }
+
+
+
+    /**
+     * Test message handler with CallableStatement
+     * <ul>
+     *   <li> Insert duplicate row -- Mapped to Info Message</li>
+     *   <li> Drop table that do not exist -- Mapped to ignore </li>
+     * </ul>
+     */
+    @Test
+    public void testMsgHandlerWithCallableStatement() throws Exception {
+        try (SQLServerConnection conn = getConnection()) {
+
+            class TestMsgHandler implements ISQLServerMessageHandler 
+            {
+                int numOfCalls      = 0;
+                int numOfDowngrades = 0;
+                int numOfDiscards   = 0;
+
+                @Override
+                public ISQLServerMessage messageHandler(ISQLServerMessage srvErrorOrWarning)
+                {
+                    numOfCalls++;
+                    ISQLServerMessage retObj = srvErrorOrWarning;
+
+                    if (srvErrorOrWarning.isErrorMessage()) {
+
+                        // Downgrade: 2601 -- Cannot insert duplicate key row in object 'dbo.#msghandler_tmp_table' with unique index 'ix_id'. The duplicate key value is (1)
+                        if (2601 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = srvErrorOrWarning.getSQLServerMessage().toSQLServerInfoMessage();
+                            numOfDowngrades++;
+                        }
+
+                        // Downgrade: 3701 -- Cannot drop the table '#msghandler_tmp_table', because it does not exist or you do not have permission.
+                        if (3701 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    if (srvErrorOrWarning.isInfoMessage()) {
+
+                        // Discard: 3621 -- The statement has been terminated.
+                        if (3621 == srvErrorOrWarning.getErrorNumber()) {
+                            retObj = null;
+                            numOfDiscards++;
+                        }
+                    }
+
+                    return retObj;
+                }
+            }
+            TestMsgHandler testMsgHandler = new TestMsgHandler();
+
+            // Create a massage handler
+            conn.setServerMessageHandler(testMsgHandler); 
+
+            // SQL to create procedure
+            String sqlCreateProc = ""
+                    + "CREATE PROCEDURE #msghandler_tmp_proc( \n"
+                    + "    @out_row_count INT OUTPUT \n"
+                    + ") \n"
+                    + "AS \n"
+                    + "BEGIN \n"
+                    + "    -- Create a dummy table, with index \n"
+                    + "    CREATE TABLE #msghandler_tmp_table(id int, c1 varchar(255)) \n"
+                    + "    CREATE UNIQUE INDEX ix_id ON #msghandler_tmp_table(id) \n"
+                    + "     \n"
+                    + "    -- Insert records 1 \n"
+                    + "    INSERT INTO #msghandler_tmp_table VALUES(1, 'row 1') \n"
+                    + "     \n"
+                    + "    -- Insert records 1 -- Again, which will FAIL, but the message handler will downgrade it into a INFO Message \n"
+                    + "    INSERT INTO #msghandler_tmp_table VALUES(1, 'row 1 - again - msg handler downgrades it') \n"
+                    + "     \n"
+                    + "    -- Count records \n"
+                    + "    SELECT @out_row_count = count(*) \n"
+                    + "    FROM #msghandler_tmp_table \n"
+                    + "     \n"
+                    + "    -- Drop the table \n"
+                    + "    DROP TABLE #msghandler_tmp_table \n"
+                    + "     \n"
+                    + "    -- Drop the table agin... The message handler will DISCARD the error\n"
+                    + "    DROP TABLE #msghandler_tmp_table \n"
+                    + "     \n"
+                    + "    RETURN 1 \n"
+                    + "END \n"
+                    ;
+
+            // Create the proc
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate(sqlCreateProc);
+                assertEquals(0, getWarningCount("Conn" , conn .getWarnings()), "Expecting NO SQLWarnings from 'create proc', at Connection.");
+                assertEquals(0, getWarningCount("Stmnt", conn .getWarnings()), "Expecting NO SQLWarnings from 'create proc', at Statement.");
+            }
+
+            // Execute the proc
+            try (CallableStatement cstmnt = conn.prepareCall("{ ? =call  #msghandler_tmp_proc(?) }")) { 
+                cstmnt.registerOutParameter(1, Types.INTEGER);
+                cstmnt.registerOutParameter(2, Types.INTEGER);
+                cstmnt.execute();
+                int procReturnCode = cstmnt.getInt(1);
+                int procRowCount   = cstmnt.getInt(2);
+
+                assertEquals(1, procReturnCode, "Expecting ReturnCode 1 from the temp procedure.");
+                assertEquals(1, procRowCount  , "Expecting procRowCount 1 from the temp procedure.");
+
+                assertEquals(1, getWarningCount("Conn"  , conn  .getWarnings()), "Expecting NO SQLWarnings from 'exec proc', at Connection.");
+                assertEquals(0, getWarningCount("CStmnt", cstmnt.getWarnings()), "Expecting NO SQLWarnings from 'exec proc', at CallableStatement.");
+            }
+
+            // numOfCalls to the message handler should be: 3
+            assertEquals(3, testMsgHandler.numOfCalls, "Number of message calls to the message handler.");
+
+            // numOfDowngrades in the message handler should be: 1
+            assertEquals(1, testMsgHandler.numOfDowngrades, "Number of message Downgrades in the message handler.");
+
+            // numOfDiscards in the message handler should be: 2
+            assertEquals(2, testMsgHandler.numOfDiscards, "Number of message Discards in the message handler.");
+
+        } catch (SQLException ex) {
+            fail(TestResource.getResource("R_unexpectedErrorMessage"));
+        }
+    }
+    
+
+
+
+    /**
+     * Test message handler with CallableStatement -- and "feedback" messages
+     * <p>
+     * Do a "long running procedure" and check that the message handler receives the "feedback" messages
+     */
+    @Test
+    public void testMsgHandlerWithProcedureFeedback() throws Exception {
+        try (SQLServerConnection conn = getConnection()) {
+
+            class TestMsgHandler implements ISQLServerMessageHandler 
+            {
+                int numOfCalls = 0;
+                Map<String, Long> feedbackMsgTs = new LinkedHashMap<>();
+
+                @Override
+                public ISQLServerMessage messageHandler(ISQLServerMessage srvErrorOrWarning)
+                {
+                    numOfCalls++;
+
+                    if (50_000 == srvErrorOrWarning.getErrorNumber()) {
+                        //System.out.println("DEBUG: testMsgHandlerWithProcedureFeedback.messageHandler(): FEEDBACK: " + srvErrorOrWarning.getErrorMessage());
+                        // Remember when the message was received
+                        feedbackMsgTs.put(srvErrorOrWarning.getErrorMessage(), System.currentTimeMillis());
+                    }
+
+                    return srvErrorOrWarning;
+                }
+            }
+            TestMsgHandler testMsgHandler = new TestMsgHandler();
+
+            // Create a massage handler
+            conn.setServerMessageHandler(testMsgHandler); 
+
+            int doSqlLoopCount = 4;
+            // SQL to create procedure
+            String sqlCreateProc = ""
+                + "CREATE PROCEDURE #msghandler_feeback_proc \n"
+                + "AS \n"
+                + "BEGIN \n"
+                + "    DECLARE @loop_cnt INT = " + doSqlLoopCount + " \n"
+                + "    DECLARE @feedback VARCHAR(255) \n"
+                + " \n"
+                + "    WHILE (@loop_cnt > 0) \n"
+                + "    BEGIN \n"
+                + "        WAITFOR DELAY '00:00:01' \n"
+                + " \n"
+                + "        SET @feedback = 'In proc, still looping... waiting for loop_count to reach 0. loop_count is now at: ' + convert(varchar(10), @loop_cnt) \n"
+                + "        RAISERROR(@feedback, 0, 1) WITH NOWAIT \n"
+                + " \n"
+                + "        SET @loop_cnt = @loop_cnt - 1 \n"
+                + "    END \n"
+                + " \n"
+                + "    RETURN @loop_cnt \n"
+                + "END \n"
+                ;
+
+            // Create the proc
+            try (Statement stmnt = conn.createStatement()) { 
+                stmnt.executeUpdate(sqlCreateProc);
+                assertEquals(0, getWarningCount("Conn" , conn .getWarnings()), "Expecting NO SQLWarnings from 'create proc', at Connection.");
+                assertEquals(0, getWarningCount("Stmnt", conn .getWarnings()), "Expecting NO SQLWarnings from 'create proc', at Statement.");
+            }
+
+            // Execute the proc
+            try (CallableStatement cstmnt = conn.prepareCall("{ ? =call  #msghandler_feeback_proc }")) { 
+                cstmnt.registerOutParameter(1, Types.INTEGER);
+                cstmnt.execute();
+                int procReturnCode = cstmnt.getInt(1);
+
+                assertEquals(0, procReturnCode, "Unexpected ReturnCode from the temp procedure.");
+
+                assertEquals(0,              getWarningCount("conn"  , conn  .getWarnings()), "Unexpected Number Of SQLWarnings from 'exec proc', at Connection.");
+                assertEquals(doSqlLoopCount, getWarningCount("cstmnt", cstmnt.getWarnings()), "Unexpected Number Of SQLWarnings from 'exec proc', at CallableStatement.");
+            }
+
+            // numOfCalls to the message handler should be: #
+            assertEquals(doSqlLoopCount, testMsgHandler.numOfCalls, "Number of message calls to the message handler.");
+
+            // Loop all received messages and check that they are within a second (+-200ms)
+            long prevTime = 0;
+            for (Entry<String, Long> entry : testMsgHandler.feedbackMsgTs.entrySet()) {
+                if (prevTime == 0) {
+                    prevTime = entry.getValue();
+                    continue;
+                }
+
+                long msDiff = entry.getValue() - prevTime;
+                if (msDiff < 800 || msDiff > 1200) {
+                    fail("Received Messages is to far apart. They should be approx 1000 ms. msDiff=" + msDiff + " Message=|" + entry.getKey() + "|.");
+                }
+
+                prevTime = entry.getValue();
+            }
+
+        } catch (SQLException ex) {
+            fail(TestResource.getResource("R_unexpectedErrorMessage"));
+        }
+    }
+    
+}
diff --git a/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java b/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java
index d17fa146..1ce9cd19 100644
--- a/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java
+++ b/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java
@@ -62,6 +62,9 @@ public abstract class AbstractTest {
     protected static String tenantID;
     protected static String[] keyIDs = null;
 
+    protected static String linkedServer = null;
+    protected static String remoteUser = null;
+    protected static String remotePassword = null;
     protected static String[] enclaveServer = null;
     protected static String[] enclaveAttestationUrl = null;
     protected static String[] enclaveAttestationProtocol = null;
@@ -197,6 +200,10 @@ public abstract class AbstractTest {
 
         clientKeyPassword = getConfiguredProperty("clientKeyPassword", "");
 
+        linkedServer = getConfiguredProperty("linkedServer", null);
+        remoteUser = getConfiguredProperty("remoteUser", null);
+        remotePassword = getConfiguredProperty("remotePassword", null);
+
         kerberosServer = getConfiguredProperty("kerberosServer", null);
         kerberosServerPort = getConfiguredProperty("kerberosServerPort", null);