Leveraging Exception.Data

Norm Bryar
5 min readJun 12, 2022

--

.Net’s Exception class has had, for years, a collection property, Data, that I think too few devs avail themselves of. We’ll explore a couple ways it’s handy.

The public IDictionary Data is writable, even on exceptions that have already been thrown, and as such it’s perfect for adding context to an error, e.g. to note the business-logic details that gave rise to a resource-layer fault.

The simplest example is just differentiating a private failure (logic error, server/environment disruption, etc.) from a bad-request.

public static class ExceptionExtensions
{
public const string IsClientErrKey = "isClientErr";
public static TExcept AsClientError<TExcept>(this TExcept ex)
where TExcept : Exception
{
ex.Data[IsClientErrKey] = true;
return ex;
}
public static bool IsClientError(this Exception ex) =>
((ex.Data[IsClientErrKey] is bool b) && b);
}

You’ve probably guessed the typical use-case already.

// Some deep library code
{ ...
throw new DivideByZeroException(...);
...
}
// Upstream processing, which knows err came from user-input
catch (Exception ex)
{
ex.AsClientError();
throw;
}
// Controller layer err handler middleware
ex.IsClientError() // Controller layer err handler
? ...return http400...
: ...return http500...

Note we can adorn the existing exception without having to wrap it in an outer exception, the throw; just works. You always still can wrap, of course, throw new OurApiException(ex, …).AsClientError();or just adorn in the ctor public OurApiException(…) { … this.AsClientError(); … }.

Lookout for the KeyNotFoundException! You may be screaming about IsClientError. Actually, no, the specialized dictionary behind Exception.Data will give you a null object if the key isn’t found (which yields false from the … is bool b clause).

Generalizing The Idea

Can we add more keys to Data? You bet’cha. You can code dedicated set/fetch pairs for other well-known names, of course, or just let devs pick whatever keys they wish.

public static class ExceptionExtensions
{
public static TExcept WithData<TExcept>(
this TExcept ex,
params (string Key, string Val)[]? dataPairs)
where TExcept : Exception
{
foreach ((string key, string val)
in dataPairs ?? Array.Empty<(string,string)>())
{
ex.Data[key] = val;
}
return ex;
}
...

with a use case like

// Constants.cs
public static class Constants
{
public const string BacgroundTaskKey = "bg-task";
public const string EndPointHostKey = "endpt-host";
... correlation-id key, tenant-id key, etc. etc...
}
// (Re-)Throwing site
ex.WithData(
(Constants.BacgroundTaskKey, "myTask"),
(Constants.EndPointHostKey, "foo.bar.baz.com") );

Here we assume someone up the call chain might inspect the exception and act pieces of this data, e.g. retry against a secondary-host or something. (Even tailoring error-handling via keys isn’t a goal for you, constants.cs might still be handy to avoid devs causing name-collision on their keys.)

Nah, I’d just add a LogError to the (Re-)Throwing Site. Yeah, that’d be the go-to tactic. However, many devs bemoan the approach. Perf devs bemoan how the log-aggregator is stealing too many runtime cycles. CoGS devs bemoan how the logging ingress, retention/storage, and query-join costs eat into profits. SRE/DRI devs may bemoan poor distributed-trace joins in diagnosing issues. So one advantage might be to consolidate more of the context info before emitting the log entry, i.e. have Exception.Data convey context.

ToStringEx

Of course, your logging layer will call Exception’s ToString override, which sadly does not walk the Data dictionary. Nor are there extension-points to teach it to. sigh. Happily, the format’s pretty stable after 20 years of .Net precedence; I don’t feel bad forking it to aToStringEx in ourExceptionExtensions.

public static string ToStringEx(this Exception? ex)
{
if (ex == null)
return string.Empty;
string className = ex.GetType().Name;
string dataPart = DataString(ex.Data);
string message = ex.Message;
string innerTxt = ex.InnerException.ToStringEx();
string stackTrc = ex.StackTrace;
int len = className.Length;

checked {
if (!string.IsNullOrEmpty(message))
len += 2 + message.Length;
if (!string.IsNullOrEmpty(dataPart))
len += dataPart.Length;
if (ex.InnerException != null)
{
len += "\r\n".Length + " ---> ".Length +
innerTxt.Length + "\r\n".Length +
3 + InnerExSuffix.Length;
}
if (stackTrc != null)
len += "\r\n".Length + stackTrc.Length;
Span<char> buffer = len < 4_096 // tune as you see fit
? stackalloc char[len]
: new char[ len ];
Span<char> dest = buffer;
Write( className, ref dest );
if (!string.IsNullOrEmpty(message))
{
Write( ": ", ref dest );
Write( message, ref dest );
}
if (!string.IsNullOrEmpty(dataPart))
{
Write( dataPart, ref dest );
}
if (!string.IsNullOrEmpty(innerTxt))
{
Write("\r\n", ref dest);
Write(" ---> ", ref dest);
Write(innerTxt, ref dest);
Write("\r\n", ref dest);
Write(" ", ref dest);
Write(InnerExSuffix, ref dest);
}
if (!string.IsNullOrEmpty(stackTrc))
{
Write( "\r\n", ref dest );
Write( stackTrc, ref dest );
}
return new string(buffer); } // ----
static void Write(string src, ref Span<char> dst)
{
ReadOnlySpan<char> srcSpn = src;
srcSpn.CopyTo(dst);
dst = dst.Slice(src.Length);
}
}
private static string DataString(IDictionary data)
{
if (data.Count <= 0)
return string.Empty;
// Am I being lazy not using StringBuilder or spans?
// Yes, but I assume .Data is rare among Exceptions
// which are rare (and expensive anyway) on your server.
return "\r\n (with Data: " +
string.Join( ",", DataPairs(data) ) + ")";
// ---
static IEnumerable<string> DataPairs(IDictionary data)
{
foreach( object pairObj in data )
{
if (pairObj is DictionaryEntry pair)
yield return $"[{pair.Key}]={pair.Value}";
}
}
}

Now your logging middleware could callex.ToStringEx() to yield something like this

ArgumentOutOfRangeException: my message
(with Data: [bg-task]=myTask,[endpt-host]=foo.bar.baz.com)
---> InvalidOperationException: bad inner op
(with Data: [sancto-sanctorum]=inner-sanctum stuff)
--- End of inner exception stack trace ---

Recording Who Set the Data

Should you find that devs have gone wild adding Data pairs from all over, you may be able to record the provenance of the annotation. The following splits the call-stack to the original and rethrow locations.

[System.Diagnostics.StackTraceHidden]  // .Net6 required
[System.Diagnostics.CodeAnalysis.DoesNotReturn]
public static void RethrowWithData(this Exception ex, string key, string val)
{
var dispInfo =
System.Runtime.ExceptionServices
.ExceptionDispatchInfo.Capture(ex);
ex.Data[key] = val;
dispInfo.Throw();
}

(some nice bonus attributes and runtime methods for you there ;- )

Conclusion

Exception.Data may be able to bolster your error-handling without needing hardly any advanced gymnastics on your side. In particular, you may be able to reduce extraneous log entries by conveying that data instead via Exception.Data.

As always, would be delighted to hear from anyone whose tackled this a different way or has refining ideas to discuss. Thanks for reading.

— — — — — —

Addendum: The ToStringEx() above really should use String.Create and Span<char>

--

--

Norm Bryar

A long-time, back-end web service developer enamored with .Net and C#, code performance, and techniques taming drudgery or increasing insight.