5. Add FHIR Exception Handling
Your Patient provider already returns several errors: HTTP 400 for an invalid ICN, 403 when a veteran requests another patient’s record, and 404 for a patient that MVI doesn’t know. But what happens when the Mobile MVI Service itself fails? Right now, the RestClient exception escapes from MviClient, and your client gets a generic HTTP 500. On this page, you learn how FHIR services report errors, and you translate failures of the Mobile MVI Service into a clear response.
How FHIR Errors Work
A FHIR service doesn’t use Spring’s @ControllerAdvice or ResponseStatusException. Instead, HAPI FHIR catches every exception that extends BaseServerResponseException and returns its HTTP status, with an OperationOutcome resource as the response body. Any other exception becomes HTTP 500 Internal Server Error.
HAPI has an exception for each common status:
| Status | HAPI exception |
|---|---|
400 Bad Request |
|
401 Unauthorized |
|
403 Forbidden |
|
404 Not Found |
|
422 Unprocessable Entity |
|
500 Internal Server Error |
|
501 Not Implemented |
|
Any other status |
|
You have seen the result already. The response for an invalid ICN looks like this:
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "Invalid patient identifier, expected an ICN"
}
]
}
Each issue has a severity, a code from the FHIR issue type value set that tells clients what kind of problem it is, and diagnostics, a message for people.
Tour the Error Catalog
Open exception/ErrorMessage.java. It’s a catalog of every message your service returns to clients. Keeping them in one place keeps the wording consistent, and makes the messages easy to review: a message must never reveal more than the client should know.
Then open exception/InvalidIcnException.java, the exception behind the 400 response above. It shows the pattern for your own exceptions:
-
It extends the HAPI exception for its status,
InvalidRequestException. -
It takes its message from
ErrorMessage. -
Its message doesn’t repeat the identifier from the request. HAPI logs the message of every error response, logs must never contain patient identifiers, and a client could send another kind of patient identifier, such as an SSN, by mistake. A second constructor takes another message from
ErrorMessage;PatientAccessuses it for an identifier with the wrong system, with a message that names the expected system instead. -
When a message does include request data that isn’t a patient identifier, such as the encounter status on page 8, pass the data through
Sanitizer.sanitizefirst. Echoing request data back unchanged would let a client inject line breaks into your logs and responses. -
It adds an issue with the code
invalidto theOperationOutcome, so clients can tell the kind of error without parsing the message.
Clean Up the Catalog
Since page 3, no code uses the NOT_IMPLEMENTED message anymore. Replace it with a message for an unavailable downstream service, so the end of ErrorMessage looks like this:
/** No patient was found for the requested ICN. */
PATIENT_NOT_FOUND("No patient was found for the requested ICN"),
/** A downstream service failed or couldn't be reached. */
DOWNSTREAM_UNAVAILABLE("%s is unavailable, try again later");
Create the Downstream Service Exception
When the Mobile MVI Service fails, your service can’t answer the request, but the problem is probably temporary. The right status for that is 503 Service Unavailable, which tells clients they may try again later. HAPI has no exception class for 503, so you create one.
Create DownstreamServiceException in the exception package:
package gov.va.mobile.fhirstarter.v1.exception;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import org.hl7.fhir.r4.model.OperationOutcome;
import java.io.Serial;
/**
* Thrown when a downstream service, such as the Mobile MVI Service, fails or can't be reached.
*
* <p>HAPI FHIR returns this exception as an HTTP 503 Service Unavailable response with the {@link OperationOutcome}
* created here as the response body. The client sees which service is unavailable, but not the downstream error
* itself; the original exception is kept as the cause for logging.
*
* @since 1.0
*/
public class DownstreamServiceException extends BaseServerResponseException {
@Serial
private static final long serialVersionUID = 1L;
/**
* HTTP status 503 Service Unavailable, which tells clients they may try again later.
*/
private static final int SERVICE_UNAVAILABLE = 503;
/**
* Creates the exception for the given downstream service.
*
* @param serviceName the name of the downstream service, which is shown to the client
* @param cause the exception thrown when calling the downstream service
*/
public DownstreamServiceException(final String serviceName, final Throwable cause) {
this(ErrorMessage.DOWNSTREAM_UNAVAILABLE.format(serviceName), cause, new OperationOutcome());
}
/**
* Creates the exception, adding an issue with the message to the {@link OperationOutcome} returned to the client.
*
* @param message the error message
* @param cause the exception thrown when calling the downstream service
* @param outcome the operation outcome returned to the client
*/
private DownstreamServiceException(final String message, final Throwable cause, final OperationOutcome outcome) {
super(SERVICE_UNAVAILABLE, message, cause, outcome);
outcome.addIssue()
.setSeverity(OperationOutcome.IssueSeverity.ERROR)
.setCode(OperationOutcome.IssueType.TRANSIENT)
.setDiagnostics(message);
}
}
-
BaseServerResponseExceptionis the base class of all HAPI server exceptions. Its constructor takes the status, the message, the cause, and theOperationOutcome. -
The issue code
transienttells clients that the problem is temporary. -
The client only learns which service is unavailable. The downstream error itself stays inside your service, as the cause of the exception, for your logs. Passing a downstream error through unchanged would reveal internal details, and could mislead the client: a 401 from the Mobile MVI Service, for example, would look as if the client’s own JWT had been rejected.
Translate MVI Failures
Update MviClient so it translates failures. Replace the contents of MviClient.java with the following:
package gov.va.mobile.fhirstarter.v1.client;
import gov.va.mobile.boot.restclient.RestClientManager;
import gov.va.mobile.fhirstarter.v1.exception.DownstreamServiceException;
import gov.va.mobile.mvi.v1.model.Person;
import gov.va.mobile.security.HeaderNames;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.util.Optional;
import java.util.function.Supplier;
/**
* Client for the Mobile MVI Service, which looks up patients in the VA Master Veteran Index (MVI).
*
* <p>The REST client is configured by the {@code va.mobile.restclient.registration.mvi.*} properties. Local
* deployments call the WireMock stubs in {@code kubernetes/components/dev/wiremock-data/mvi} instead of the real
* service.
*
* @since 1.0
*/
@Slf4j
@Component
public class MviClient {
/**
* Registration ID of the Mobile MVI Service REST client ({@code va.mobile.restclient.registration.mvi.*}).
*/
public static final String REGISTRATION_ID = "mvi";
/**
* Name of the service, as shown to clients when it is unavailable.
*/
private static final String SERVICE_NAME = "The Mobile MVI Service";
/**
* REST client for the Mobile MVI Service, created by the Mobile Framework from the registration properties.
*/
private final RestClient restClient;
/**
* Creates the client.
*
* @param restClientManager provides the REST clients of the registrations in the service properties
*/
public MviClient(final RestClientManager restClientManager) {
this.restClient = restClientManager.getClient(REGISTRATION_ID);
}
/**
* Returns the demographics of the patient with the given ICN.
*
* <p>The caller's JWT is passed on, because the Mobile MVI Service authorizes every request.
*
* @param icn the ICN of the patient
* @param jwt the JWT of the caller
* @return the demographics of the patient, or empty if MVI has no record of the patient
* @throws DownstreamServiceException if the Mobile MVI Service fails or can't be reached (HTTP 503)
*/
public Optional<Person> getPerson(final String icn, final String jwt) {
return send(() -> restClient.get()
.uri("/patients/ICN/{icn}/mvi/person", icn)
.header(HeaderNames.VAMF_JWT_HEADER, jwt)
.retrieve()
.body(Person.class));
}
/**
* Sends a request to the Mobile MVI Service and translates its failures.
*
* @param request the request, which returns the response body
* @param <T> the type of the response body
* @return the response body, or empty if MVI has no record of the patient
* @throws DownstreamServiceException if the Mobile MVI Service fails or can't be reached (HTTP 503)
*/
private <T> Optional<T> send(final Supplier<T> request) {
try {
// The Mobile MVI Service answers 204 No Content when MVI has no record, which results in a null body
return Optional.ofNullable(request.get());
} catch (final HttpClientErrorException.NotFound e) {
log.debug("The Mobile MVI Service has no record of the requested patient");
return Optional.empty();
} catch (final HttpServerErrorException | ResourceAccessException e) {
// Log the type of failure only: messages and response bodies can contain patient identifiers
final String failure = e.getClass().getSimpleName();
log.warn("The Mobile MVI Service request failed: {}", failure);
throw new DownstreamServiceException(SERVICE_NAME, e);
}
}
}
The new private send method sends a request and handles its outcome in one place, so the next request you add to MviClient gets the same handling:
-
A 204 or 404 still means "no record".
-
HttpServerErrorException(any 5xx status) andResourceAccessException(a timeout, or a connection that is refused or reset) mean the service failed. They become aDownstreamServiceException. -
Any other exception, such as a 400 from the Mobile MVI Service, means your service sent a bad request. That’s a bug, and it stays an HTTP 500.
Notice what the log message contains: only the type of the failure. Exception messages and response bodies can contain the URL of the request, which includes the patient’s ICN. Never log PHI or PII.
Add Error Stubs
To see the new handling in action, add two stubs to kubernetes/components/dev/wiremock-data/mvi. person-10108V420871.json makes the Mobile MVI Service fail for PATIENT EIGHT:
{
"request": {
"method": "GET",
"urlPath": "/mvi/v1/patients/ICN/10108V420871/mvi/person"
},
"response": {
"status": 500,
"body": "Internal Server Error"
}
}
person-10110V004877.json uses a WireMock fault to reset the connection for PATIENT TEN, as if the service crashed in the middle of the request:
{
"request": {
"method": "GET",
"urlPath": "/mvi/v1/patients/ICN/10110V004877/mvi/person"
},
"response": {
"fault": "CONNECTION_RESET_BY_PEER"
}
}
Add both files to the mvi-wiremock-mappings ConfigMap in kubernetes/components/dev/kustomization.yaml:
- name: mvi-wiremock-mappings
behavior: create
files:
- wiremock-data/mvi/version.json
- wiremock-data/mvi/person-10115V737384.json
- wiremock-data/mvi/person-10132V467385.json
- wiremock-data/mvi/person-1012845331V153043.json
- wiremock-data/mvi/person-10108V420871.json
- wiremock-data/mvi/person-10110V004877.json
Try It
Stop skaffold dev, run ./scripts/localize.sh force, and start skaffold dev --rpc-port 50051 --port-forward=true again, so WireMock loads the new stubs. Then read PATIENT EIGHT:
curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/Patient/10108V420871' | jq
Response (HTTP 503):
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "transient",
"diagnostics": "The Mobile MVI Service is unavailable, try again later"
}
]
}
Reading PATIENT TEN, Patient/10110V004877, returns the same response. In the service log, you see a warning with the type of each failure, InternalServerError and ResourceAccessException. For the reset connection, you also see an INFO line from the HTTP client, which retries the request once before it gives up.
Update the Tests
| To check your tests, compare them with the test classes in the "FHIR starter page 5" commit of the reference branch; see When You Get Stuck. |
Test the Failures
Make these changes to PatientResourceITCase:
-
Add three constants after
UNKNOWN_ICN:/** * ICN of a patient for whom the Mobile MVI Service stub returns HTTP 500. */ private static final String MVI_ERROR_ICN = "10108V420871"; /** * ICN of a patient for whom the Mobile MVI Service stub resets the connection. */ private static final String MVI_UNREACHABLE_ICN = "10110V004877"; /** * Message of the HTTP 503 response when the Mobile MVI Service fails. */ private static final String MVI_UNAVAILABLE = "The Mobile MVI Service is unavailable, try again later"; -
Add the tests below before the
readByIdhelper method. The first two tests are complete; implement the third:/** * Verify a Mobile MVI Service error results in HTTP 503. */ @Test void readById_mviError() { final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt()); final IReadExecutable<Patient> query = readById(client, MVI_ERROR_ICN); assertThatThrownBy(query::execute) .isInstanceOfSatisfying(UnclassifiedServerFailureException.class, e -> assertThat(e.getStatusCode()).isEqualTo(503)) .hasMessageContaining(MVI_UNAVAILABLE); } /** * Verify a Mobile MVI Service that can't be reached results in HTTP 503. */ @Test void readById_mviUnreachable() { final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt()); final IReadExecutable<Patient> query = readById(client, MVI_UNREACHABLE_ICN); assertThatThrownBy(query::execute) .isInstanceOfSatisfying(UnclassifiedServerFailureException.class, e -> assertThat(e.getStatusCode()).isEqualTo(503)) .hasMessageContaining(MVI_UNAVAILABLE); } /** * Verify a search also results in HTTP 503 when the Mobile MVI Service fails. */ @Test void searchByIcn_mviError() { // TODO: implement } -
Add the import
import ca.uhn.fhir.rest.server.exceptions.UnclassifiedServerFailureException;. The HAPI client has no exception class for 503 either, so it throws anUnclassifiedServerFailureExceptionwith the status code.-
readById_mviErrorandreadById_mviUnreachablediffer only in the ICN: a failure response and a connection failure both result in HTTP 503. -
For
searchByIcn_mviError, usesearchByIcn(client, MVI_ERROR_ICN), and check for the same status code.
-
Test the Exception
Create a unit test for the new exception, DownstreamServiceExceptionTest, in the test package exception, and implement it:
package gov.va.mobile.fhirstarter.v1.exception;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link DownstreamServiceException}.
*/
class DownstreamServiceExceptionTest {
/**
* Verify the exception results in HTTP 503 with an OperationOutcome that names the unavailable service, and keeps the
* original exception as its cause.
*/
@Test
void downstreamServiceUnavailable() {
// TODO: implement
}
}
-
Model the test on
InvalidIcnExceptionTest, next to it. -
Create the exception with a service name and a cause, for example
new IllegalStateException("Connection reset"). -
Check the status code (
getStatusCode()), the message, thatgetCause()is the exception you passed, and the severity, code (IssueType.TRANSIENT), and diagnostics of the issue ingetOperationOutcome().
Commit Your Work
git add .
git commit -m "Translate Mobile MVI Service failures into HTTP 503"
git push
See It in a Real Service
-
chapi-proxy-service:
src/main/java/gov/va/mobile/chapi/v1/service/exception/ErrorMessage.java, a larger error catalog -
cerner-fhir-adapter: the
src/main/java/gov/va/mobile/cfa/v1/exceptionpackage, exceptions for many kinds of request errors -
vista-fhir-note-api:
src/main/java/gov/va/mobile/vfna/v1/services/MobileMviClient.java, which translates MVI errors
You’re Done When
-
A failing or unreachable Mobile MVI Service results in HTTP 503 with a readable
OperationOutcome -
Your logs show the type of the failure, but no patient data
-
The error catalog has no unused messages
-
All tests pass, and your work is committed and pushed
Continue to 6. Add an Operation and an Interceptor to add an operation and an interceptor.