A failed call comes in two kinds. One is a business rule stopping something in the normal course of things, an order with no customer selected, a status that cannot move that way, and that message is written for the user. The other is an exception the code did not expect, whose message may carry a file path from the server, and that one is written for the developer. Both look identical by the time they reach the caller, both being the error field in a JSON-RPC payload. Without telling them apart the only safe thing to display is "request failed", and the half the user was supposed to see goes with it. Telling them apart means that field needs a contract of its own.
This post covers:
- What form the framework uses to let the caller know when a business rule stops an action
- How error codes are allocated, and which ones land on the HTTP status code side
- Which exception messages can go out unchanged, and what that whitelist is judged on
- How the caller turns an error code back into an exception, and why the mapping at both ends collapsed into one
1. Exceptions as a business flow's interruption message
An order gets stopped by a rule halfway through a save. The code that stopped it could be in three different places: the definition layer's rule evaluator, a check step a business object overrode, or the framework's own up front validation. All three sit several method calls away from dispatch.
Carrying that interruption message in a return value means every layer in between has to catch it, judge it, and pass it up again. Miss any one of those layers and the message stops there: the save runs to completion, the caller receives nothing, and the layer that dropped it looks perfectly normal in the source.
An exception's default direction is the opposite. With nobody catching it, it travels all the way up, and making it disappear takes somebody deliberately writing a catch to swallow it. The framework therefore throws, and that decision is visible in the extension point signatures: DoBeforeSave(SaveContext) returns void, and never had a route for sending a message back in the first place.
UserMessageException is positioned as a business flow's interruption message rather than a real program error. Control flow is aborted because this thing cannot go on, and that message was always meant to reach the user unchanged.
One type catches three sources:
| Which kind of check | Who throws it | Where the message comes from |
|---|---|---|
| A rule declared in a definition file | The form rule evaluator |
FormRule's Message property |
| A check overridden on a business object | The application's own code | A string in that code |
| The framework's own up front validation | The framework | A framework string |
The first of those is the last line of the rule validating stretch in the evaluator, FormExpressionCalculator, and is the exit of the whole declarative rule mechanism:
if (!_evaluator.Evaluate<bool>(rule.Condition, NarrowVariables(rule.Condition, variables), timeZoneId))
throw new UserMessageException(rule.Message);
All three sources throw the same type, so no layer on the way up has to know whether this interruption message was declared or written. What dispatch sees is always the same thing.
At the moment of stopping there is no transaction to roll back yet, which is the other side of the boundary in Extension Points on a Business Object and When to Override Them. Rules run outside the writing step, and aborting a write that has not started needs no compensating action at all.
2. Error codes and the line where HTTP semantics stop
What The Connector and the Convergence of Client Responsibilities showed on the client was two different catches, and what JSON-RPC 2.0: Protocol Choice and the Request Pipeline showed on the server was one line: whether a failure lands before or after the request is accepted decides whether it travels as an HTTP status code or in the error field. There is a third way of saying that same decision, which is the error code itself.
Error codes come from two places, some being JSON-RPC 2.0's standard codes and the rest taken from the range the specification reserves for servers. These are the ones that actually reach the caller:
| Code | Value | Currently produced by | Where it lands |
|---|---|---|---|
ParseError |
-32700 | The transport layer cannot read the JSON | Before acceptance, HTTP 400 |
InvalidRequest |
-32600 | The transport layer: media type, an empty body, a missing method, an API key that does not pass, or a Bearer or Guid format that is wrong | Before acceptance, HTTP 415 / 400 / 401 |
InternalError |
-32000 | The executor: an exception not on the registry. The transport layer: an unexpected exception outside the executor | After acceptance, HTTP 200; the transport layer's version is HTTP 500 |
CompanyNotEntered |
-32002 | The executor: CompanyNotEnteredException
|
After acceptance, HTTP 200 |
CompanyAccessDenied |
-32003 | The executor: CompanyAccessDeniedException
|
After acceptance, HTTP 200 |
PermissionDenied |
-32004 | The executor: ForbiddenException
|
After acceptance, HTTP 200 |
ReplayRejected |
-32005 | The executor: ReplayRejectedException
|
After acceptance, HTTP 200 |
UserMessage |
-32099 | The executor: an interruption message meant for the user (whitelist) | After acceptance, HTTP 200 |
The codes after acceptance fall into three uses. -32000 is a real exception, where the code or the infrastructure broke and the message should not leave the server. -32099 is the reverse, holding exactly the sentence meant to be shown to the user. The named codes in between are neither, and exist so the caller can do something different on receiving them: send the user back to pick a company, degrade the screen, stop retrying. Their messages are not necessarily fit to display, since "this session has not entered a company" means nothing to a user and everything to the caller.
Which code a failure ends up at is decided by the exception type thrown, not by what kind of failure it was. Failing to find an action throws MissingMethodException, which is not registered, so it comes back as -32000. A token that does not exist or has expired throws UnauthorizedAccessException, which is registered at -32099.
UserMessage taking -32099 is deliberate. It sits at the far end of the custom range, some distance from the specific classification codes, with the unused numbers in between left for future classifications, so that ordinary business messages and failures with a definite meaning do not end up mixed into one run of consecutive numbers.
The transport layer can check a credential's shape, not its validity
For the same "not logged in", two different answers come back in practice:
# no Authorization header
HTTP 401 {"error":{"code":-32600,"message":"Missing Authorization header."}}
# a well formed token that does not exist
HTTP 200 {"error":{"code":-32099,"message":"Session key not found or expired."}}
The transport layer checks whether there is a header, whether it starts with Bearer, and whether what follows parses as a Guid. All three can be settled on the request itself without asking any layer. Whether the token is still valid means asking the session, and the session is in the layer behind, so a well formed but invalid token counts as accepted as far as the protocol is concerned, and its failure comes back in the error field.
The operational consequence is that the 401s a gateway counts include only the batch whose format was wrong, while every failure caused by an expired token is buried inside HTTP 200.
Merging error codes closes an enumeration channel
CompanyAccessDenied covers three causes: the company does not exist, the company is disabled, and this user is not authorized for it. All three throw the same exception on the server with the same message, and sending a company id that does not exist comes back with Company access denied.
The merge only means something if it is done in both places. Merge the code but let the message text differ and the caller can still use the messages to find valid company ids one at a time. That requirement lands on the message argument of that exception type's constructor: every cause has to use the same text. It is a convention, not something a compiler can enforce.
This technique is used at exactly one place among the error codes. Every other code is split by the kind of failure, and the finer the split the more use it is to the caller. Only when the code itself would leak whether an identifier exists does splitting finely turn into a channel that can be probed over and over.
3. Which messages can go in front of a user unchanged
An exception message can contain anything. The one for a missing definition file holds a complete file path, and the one from a database driver may carry a fragment of a statement. Handing those to the caller means giving away the server's internal structure for free.
The framework's test is a registry, JsonRpcErrorContract, and what the executor (JsonRpcExecutor) does on catching an exception is look its type up in that table:
internal static (JsonRpcErrorCode code, string message) MapException(Exception ex)
{
if (JsonRpcErrorContract.TryGetCode(ex, out var code))
return (code, ex.Message);
return (JsonRpcErrorCode.InternalError,
SysInfo.IsDebugMode ? ex.Message : "Internal server error");
}
The rows registered as -32099 are that whitelist, seven types in two batches:
| Type | Status |
|---|---|
UserMessageException |
The answer for new code, the type itself being the declaration that this sentence is for the user |
UnauthorizedAccessException |
Kept for the transition |
The ArgumentException family |
Kept for the transition |
InvalidOperationException |
Kept for the transition |
NotSupportedException |
Kept for the transition |
FormatException |
Kept for the transition |
JsonRpcException |
Kept for the transition |
The other six are kept for the transition, and will be removed as business code moves to the dedicated type.
The cost of a whitelist of exception types is that it judges who threw, not what was thrown. That has a light consequence and a heavy one, and the light one first. ParseMethod throws FormatException when it cannot produce two segments, so a protocol level format error travels as -32099, a code whose contract says this message can be displayed to the user directly. Sending a method with no period in it comes back as HTTP 200 with -32099 and Invalid method format: Ping. That sentence means nothing to a user, but it gives nothing away either.
The other is far more expensive. InvalidOperationException is scattered through every corner of the framework and the application, and any instance of it nobody expected sends its message out unchanged. That is precisely why the list is marked as transitional.
Masking replaces the whole message rather than picking out the sensitive part
An exception not on the registry always becomes one fixed sentence. The same request under the two settings:
# debug flag on
{"code":-32000,"message":"The file /Users/…/Define/FormSchema/NoSuchProg.FormSchema.xml does not exist."}
# debug flag off
{"code":-32000,"message":"Internal server error"}
The first handed the server's file system path to the caller. Masking replaces the whole thing rather than filtering because filtering means knowing which part is sensitive first, and that depends on each exception. Replacing the whole thing requires knowing nothing.
The executor and the transport layer do not read the same switch. The executor reads SysInfo.IsDebugMode, which comes from the SystemSettings definition file, while the transport layer's stretch reads the host's environment name. Both have to be right for the message not to leak. Deploy with that flag in the definition file left on and setting the host to production will not stop it.
The debug branch carries the message only, with no stack trace and no wider dump, since once that kind of content is in an API response what leaks is a good deal more than one line of path.
That decision has another cost. The executor handles the exception here and does not rethrow, so nothing above gets a chance to report it either, and what a developer has in production is that one sentence. There is a separate logging route on the server that writes the exception's type name and its original message as a row, but that route is off by default, and with it off not even the type name survives that failure.
4. The caller turns the error field back into an exception
The previous post said FinalizeResponse is where the error field is turned back into an exception, and that it reads errors before decoding. The position is settled, and what remains is what it turns into.
The whole round trip looks like this:
business object throw new UserMessageException("Please select a customer for the order.")
executor exception type → error code (-32099, "Please select …")
JSON-RPC payload {"error":{"code":-32099,"message":"Please select …"}}
caller error code → exception type throw new UserMessageException("Please select …")
screen catch (UserMessageException ex) → display ex.Message
The caller, ApiConnector, reads the same registry in the other direction:
if (JsonRpcErrorContract.TryRebuild(response.Error.Code, response.Error.Message, out var rebuilt))
throw rebuilt;
throw new InvalidOperationException($"API error: {response.Error.Code} - {response.Error.Message}");
The caller therefore does not have to recognize any concrete exception type, because that registry recognizes them for it.
Rebuilding is not restoring. The -32099 row is many to one: what the server threw could be any of the seven types on the whitelist, and what arrives back at the caller is always UserMessageException. All that comes back of the type information is one integer, and what can be restored is whatever that integer recognizes. This is a deliberate trade, since what the caller wants is whether this message can go straight to the user, not which type the server originally threw.
Adding a kind of error is therefore one edit rather than one in each of two assemblies. Written separately on both sides, the server recognizing one more code while the caller has not caught up means that code falls quietly into the general branch, and it compiles, and the tests may well not catch it. The naming convention for return types in API Contract Layering and the Type Registry is still held together by convention, and this one is not.
An in process call makes the full round trip too. The local route resolves the same executor, the exception is caught just the same, becomes a code, and is rebuilt by FinalizeResponse, so what comes back is equally a new exception instance with the original stack trace and inner exception gone. The previous post said the two routes have to be indistinguishable to the caller, and this is another bill for that goal: they are indeed indistinguishable, at the cost of the local route giving up something it had for free.
Back to Northwind
Saving an order with no customer comes back as HTTP 200 with -32099 and Please select a customer for the order. That text is not in any line of code. It is an attribute value in the case study order's FormSchema, Order.FormSchema.xml:
<FormRule RuleId="customer_required" Condition="customer_rowid != Guid.Empty"
Message="Please select a customer for the order." />
From an attribute in a definition file, through the exception the evaluator throws, an integer error code, and the exception the caller rebuilds, to that line of text on screen, no stretch of application code touches it anywhere along the way. Declarative Business Rules and Computed Fields covered only the evaluating half, and this is the other one: a rule's message is part of the definition as well, and it reaches the user without changing hands once.
The checks OrderBO takes over for itself in the step before writing use the same type. At least one detail row, a status that cannot move that way, and details locked once confirmed, all three throw UserMessageException, so a rule declared in a definition file and a check written in code are indistinguishable by the time they reach the caller. Being indistinguishable is correct, since what the caller needs to know is that this is a message for the user, not whether it was declared or written.
The -32003 above is reachable in the case study, since entering a company is called right after login, and it answers with the same sentence for a company that does not exist and one the user is not authorized for.
One of -32000's two faces is visible in the case study: its SystemSettings.xml leaves the debug flag on, so a failure to find a definition file comes back with the complete path. Turn that flag off and send the same request and back comes the fixed sentence. The same code, the same request, two answers, and the difference is one line in one definition file.
In closing
What an error contract answers is how much of this failure the other side gets to know. Too little and the caller can only display "request failed", leaving the user with no idea what to change. Too much and the server's internal structure flows out along with the error message.
- An interruption message travels as an exception, because it has to cross several layers and a return value crosses only one
- Which side of acceptance a failure lands on decides whether it travels as an HTTP status code or in the
errorfield, and the transport layer can check a credential's shape but not its validity - Whether a message can go out unchanged is decided by a whitelist of exception types, at the cost of judging who threw rather than what was thrown
- The caller rebuilds an exception from the code, and both ends read the same registry, so adding a kind of error is one edit
The third point is the one most worth taking away. "Can be shown to the user" is not judged on what the text says, it is judged on which type threw it. Whether a message holds a path or a fragment of a statement is not something the code can see at run time, while a type can be seen, is settled at compile time, and can be counted and changed.
Every post in this chapter has been asking the same thing: where does this belong. Which stretch of a session a value belongs to, which side of a transaction an extension point sits on, which layer of the contract a type is in, whether a failure lands before or after acceptance, whether a piece of state belongs to one user or the whole application, which type a message is thrown by. Position decides meaning, and position is not written in the code, it is spread across a few documents, a few comments and a few people's memories. What a contract does is write it down.
From the next post, The Scope of the Customization Layer and Overlay Granularity, the direction changes: one framework has to look different for different customers, and where those differences are allowed to land.
📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-18-error-contract
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)