Richtiges Fehlerhandling – was bedeutet das?
Warum brauchen wir Fehlerbehandlung?
Fehler! Es ist ein wohlbekanntes Wesen für Benutzer jeder entwickelten Anwendung der Welt – und mit jeder meine ich wirklich jede 😀 Es ist ein untrennbarer Bestandteil jeder Anwendung – also stellen wir uns dem!
Wir können Fehler nicht vermeiden, da nicht alles unter der Kontrolle der Entwickler liegt und nicht alles in der Welt perfekt ist. Um eine robuste Anwendung zu haben, brauchen wir eine gute Lösung zur Fehlerbehandlung.
Was ist eine angemessene Lösung zur Fehlerbehandlung?
Jede Lösung wird entwickelt, um ein Problem zu lösen. Um eine angemessene Lösung zu haben, müssen wir das Problem richtig kennen, trivial, oder? Schauen wir uns nun genauer an, was wir mit unserer Fehlerbehandlung erreichen wollen.
Ein wichtiges Ziel jeder Anwendung ist es, ihre Benutzer glücklich zu halten, auch wenn ein Fehler auftritt.
if you agree continue;
Verschiedene Benutzertypen interagieren mit der Anwendung, und jeder Typ hat seine eigenen Erwartungen, wenn ein Fehler auftritt. Sie alle glücklich zu halten bedeutet, dass wir all diese Erwartungen erfüllen müssen (keine Panik!). Lassen wir es aufschlüsseln.
Endbenutzer
Hier ist, was ich als Endbenutzer erwarte:
- Wenn ein Fehler auftritt, erwarte ich eine klare, leicht verständliche, aber dennoch beschreibende Fehlermeldung ohne unnötige technische Details.
- Wenn ich etwas tun kann, um das Problem zu lösen, würde ich mich natürlich über einen Hinweis freuen.
- Andernfalls möchte ich den Fehler einfach melden oder Unterstützung vom Help-Desk-Team erhalten können.
- Ich möchte nicht allein mit dem Fehler sein!
Help-Desk-Team
Als Mitglied des Help-Desk-Teams habe ich auch Erwartungen!
- Ich erwarte Kategorien für Fehler, damit ich die Lösung leicht finden kann.
- Es wäre schön, wenn wir dieselbe Sprache wie Endbenutzer sprechen könnten! Glaubt mir, es ist schwer, jemanden zu verstehen, der eine andere Sprache spricht.
- Ich muss wissen, ob ich das Problem selbst lösen kann oder ob ich es an das Entwicklungs-/Wartungsteam weiterleiten sollte.
Entwicklungs- oder Wartungsteam
Unsere Erwartungen? Das ist einfach! Wir haben nur eine Erwartung!
- Wir möchten alles haben! Natürlich bezogen auf den Fehler. Je mehr wir wissen, desto besser 😀
Lösung
Jetzt, da wir wissen, was die Erwartungen sind, können wir in den spaßigen Teil einsteigen.
Die bisher besprochenen Konzepte waren generisch und sprachunabhängig. Jetzt müssen wir konkret werden, damit ich die Lösung erklären kann, die wir in einigen unserer Projekte bei Cloudflight verwenden.
Ich beschreibe die Lösung im Kontext einer Webanwendung, die mit Kotlin und Spring Boot entwickelt wurde, aber das kann gerne basierend auf eurem Kontext und euren Bedürfnissen angepasst werden.
Benutzerdefinierte Ausnahme auf Anwendungsebene
Wir müssen Fehler im Zusammenhang mit der Geschäftslogik von anderen Fehlern wie Netzwerk- oder Datenbankfehlern unterscheiden, damit wir Endbenutzern nur relevante Informationen liefern können.
Wir erstellen eine Basis-Exception-Klasse namens ApplicationException. Wann immer etwas schiefgeht, wird eine Instanz dieser Exception oder einer ihrer Unterklassen geworfen. Ihr könnt so viele Unterklassen definieren, wie ihr möchtet!
open class ApplicationException(
open val code: String,
val httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
override val cause: Throwable?,
override val message: String,
) : RuntimeException()
open class ApplicationNotFoundException(
override val code: String,
override val cause: Throwable? = null,
override val message: String,
) : ApplicationException(code, HttpStatus.NOT_FOUND, cause, message)
open class ApplicationBadRequestException(
override val code: String,
override val cause: Throwable? = null,
override val message: String = "",
) : ApplicationException(code, HttpStatus.BAD_REQUEST, cause, message)
...
- code: Dieser wird als Fehlerkategorie verwendet.
- Endbenutzer können ihn verwenden, um Hilfe vom Help-Desk-Team zu erhalten. Es ist die gemeinsame Sprache zwischen Endbenutzern und dem Help-Desk-Team!
- Das Help-Desk-Team kann ihn verwenden, um die mögliche Lösung für den Fehler aus einer vorausgefüllten Tabelle nachzuschlagen, die Fehlercodes möglichen Lösungen zuordnet.
- httpStatus: Dieser wird verwendet, um den HTTP-Status basierend auf dem Fehlertyp zu setzen.
- cause: Das ist die verschachtelte Exception, falls eine vorhanden ist.
- message: Das ist… ihr habt es erraten! „Klare, leicht verständliche, aber dennoch beschreibende Fehlermeldung ohne unnötige technische Details”
Einheitliches Fehlermodell
Jetzt können wir unsere Exceptions auf Anwendungsebene von anderen Exceptions unterscheiden. Wir müssen auch eine Klasse definieren, die einen Fehler beschreibt. Alle Exceptions werden in dieses Modell übersetzt, bevor sie die Anwendung verlassen. Auf diese Weise haben wir ein einheitliches Fehlerantwortmodell, und unsere Anwendungsclients können alle Fehler auf dieselbe Weise behandeln. Nennen wir es APIErrorDTO.
data class APIErrorDTO(
val id: String,
val code: String,
val message: String,
val details: List<ErrorDetailDTO>,
)
data class ErrorDetailDTO(
val code: String,
val message: String,
)
- id: Dieser identifiziert einen Fehler eindeutig.
- Endbenutzern kann eine Schaltfläche „Fehler melden” bereitgestellt werden, die ein Ticket aus dem Fehler mit allen angehängten Details erstellt.
- Das Entwicklungsteam kann ihn auch verwenden, um die Logs und alle anderen Details im Zusammenhang mit dem Fehler zu finden.
- code und message: Diese kommen aus der Exception auf Anwendungsebene.
- details: Wenn verschachtelte Anwendungsfehler vorhanden sind, werden
codeundmessagedieser zudetailshinzugefügt, damit Benutzer mehr Informationen darüber erhalten.
Globale Exception-Behandlung
In Spring Boot können fast alle Exceptions global an einer Stelle mit der Annotation @ControllerAdvice behandelt werden. Wir fangen die Exceptions hier ab und wandeln sie in eine APIErrorDTO-Instanz um.
@ControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
...
@ExceptionHandler(ApplicationException::class)
fun handleApplicationException(exception: ApplicationException, request: WebRequest): ResponseEntity<APIErrorDTO> {
val apiErrorDTO = createAPIErrorDTO(exception)
val httpStatus = getHttpStatus(exception)
if (HttpStatus.INTERNAL_SERVER_ERROR == httpStatus) {
request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, exception, WebRequest.SCOPE_REQUEST)
}
return ResponseEntity<APIErrorDTO>(apiErrorDTO, HttpHeaders(), httpStatus)
}
fun createAPIErrorDTO(
exception: Throwable?, defaultErrorCode: String = DEFAULT_ERROR_CODE,
defaultMessage: String = ""
): APIErrorDTO {
val errorId = UUID.randomUUID().toString()
val errorCode = if (exception is ApplicationException) exception.code else defaultErrorCode
var message = if (exception is ApplicationException) exception.message else defaultMessage
val errorDetail = mutableListOf<ErrorDetailDTO>()
var cause = exception?.cause
while (cause != null) {
if (cause is ApplicationException) {
errorDetail.add(ErrorDetailDTO(cause.code, cause.message))
}
cause = cause.cause
}
return APIErrorDTO(errorId, errorCode, errorDetail, message)
}
private fun getHttpStatus(exception: ApplicationException): HttpStatus {
var httpStatus = exception.httpStatus
var cause = exception.cause
while (cause != null) {
if (cause is ApplicationException) {
httpStatus = cause.httpStatus
} else if (cause is AccessDeniedException) {
httpStatus = HttpStatus.FORBIDDEN
}
cause = cause.cause
}
return httpStatus
}
}
Wenn ihr erwartet, hier einen anderen Exception-Typ zu erhalten, könnt ihr eine neue Methode dafür hinzufügen. Dann könnt ihr handleApplicationException von dort mit entsprechenden Argumenten aufrufen. Auf diese Weise könnt ihr ein APIErrorDTO aus jeder Ausnahme erstellen. Wenn ihr zum Beispiel erwartet, eine NestedRuntimeException zu erhalten, könnt ihr folgende Methode zu eurer GlobalExceptionHandler-Klasse hinzufügen:
@ExceptionHandler(NestedRuntimeException::class)
fun nestedRuntimeExceptionTransformer(
exception: NestedRuntimeException,
request: WebRequest
): ResponseEntity<APIErrorDTO> {
return handleApplicationException(
ApplicationUnprocessableException(DEFAULT_ERROR_CODE, "a relevant message!", exception),
request
)
}
Übrigens können wir nicht alle Exceptions im GlobalExceptionHandler abfangen. Wir müssen benutzerdefinierte ErrorAttributes definieren, um alle Exceptions, auch die außerhalb unserer Kontrolle, in das APIErrorDTO zu übersetzen.
Benutzerdefinierte Fehlerattribute
Mithilfe der folgenden Klasse können wir andere Exceptions in das APIErrorDTO übersetzen.
class CustomErrorAttributes : DefaultErrorAttributes() {
override fun getErrorAttributes(webRequest: WebRequest?, options: ErrorAttributeOptions?): MutableMap<String, Any> {
val defaultErrorCode =
getStatusCode(webRequest)?.toString() ?: DEFAULT_ERROR_CODE
return createAPIErrorDTO(
this.getError(webRequest),
defaultErrorCode,
defaultMessage = getDefaultErrorMessage(webRequest)
).toMap()
}
private fun getDefaultErrorMessage(webRequest: WebRequest?): String {
val defaultErrorMessage: String
val errorMessage = webRequest?.getAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)?.toString()
defaultErrorMessage = if (!errorMessage.isNullOrBlank()) {
errorMessage
} else {
try {
val statusCode = getStatusCode(webRequest)
if (statusCode == null) UNKNOWN_ERROR_MESSAGE else HttpStatus.valueOf(statusCode).reasonPhrase
} catch (e: Exception) {
UNKNOWN_ERROR_MESSAGE
}
}
return defaultErrorMessage
}
private fun getStatusCode(webRequest: WebRequest?): Int? {
val statusCodeAttribute = webRequest?.getAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)
return if (statusCodeAttribute != null) statusCodeAttribute as Int else null
}
}
Aspektorientierte Programmierung (AOP)
Wir alle schätzen sauberen und lesbaren Code, oder? Anstatt unseren Code mit Try/Catch-Blöcken zu überfüllen, werden wir Spring AOP verwenden, um unseren Code sauber und prägnant zu gestalten. Definieren wir einen Aspekt, mit dem wir unsere Methoden dekorieren können, und entfernen wir etwas Boilerplate-Code hier und dort.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExceptionWrapper(val exception: KClass<out ApplicationException>)
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class ExceptionWrapperAspect {
@Around("@annotation(io.cloudflight.thiscouldbeyourproject.server.common.exception.ExceptionWrapper)")
fun wrapException(joinPoint: ProceedingJoinPoint): Any? {
try {
return joinPoint.proceed()
} catch (e: Throwable) {
val methodSignature: MethodSignature = joinPoint.signature as MethodSignature
val method: Method = methodSignature.method
val exceptionWrapper: ExceptionWrapper =
method.annotations.find { it is ExceptionWrapper } as ExceptionWrapper
val constructor =
exceptionWrapper.exception.constructors.firstOrNull { it.parameters.size == 1 && it.parameters.first().type.classifier == Throwable::class }
throw
constructor?.call(e) ?: ApplicationException(
code = DEFAULT_ERROR_CODE,
i18nMessage = DEFAULT_ERROR_MESSAGE,
cause = e
)
}
}
}
Mit diesen Mitteln müssen wir nur den Einstiegspunkt des Use-Cases mit der Annotation @ExceptionWrapper versehen. So könnt ihr sicherstellen, dass Endbenutzer eine angemessene Meldung erhalten, wenn etwas schiefgeht. Beachtet, dass ihr nicht alle eure Methoden mit @ExceptionWrapper annotieren müsst. Es reicht, sie nur am Einstiegspunkt des Use-Cases zu haben. Ihr könnt in einer unteren Schicht immer noch einen Fehler abfangen, wenn er behebbar ist oder wenn ihr dem Endbenutzer mehr Informationen mitteilen möchtet, die nur dort verfügbar sind, wo der Fehler auftritt. Im letzteren Fall könnt ihr den Fehler abfangen und eine Unterklasse von ApplicationException werfen!
@Transactional
@ExceptionWrapper(CreateUserException::class)
override fun createUser(user: UserChange): User {
if (EmailFormatIsInvalid(user.email))
throw EmailFormatIsInvalid()
...
return createdUser
}
class CreateUserException(cause: Throwable) : ApplicationException(
code = "ERR-CU-1",
message = "Failed to create the user",
cause = cause
)
class EmailFormatIsInvalid : ApplicationUnprocessableException(
code = "ERR-CU-2",
message = "Format of provided email is invalid. The format should look like local-part@domain e.g. example@cloudflight.io",
)
Fazit
Schauen wir nun, ob wir mit dieser Lösung alle Erwartungen unserer Benutzer erfüllt haben:
Endbenutzer
- Mit der
messagekönnen wir den Fehler beschreiben und Hinweise zur Lösung geben, wenn möglich. - Mit der
idkönnen wir unseren Benutzern ermöglichen, den Fehler mit minimalem Aufwand zu melden. - Mit dem
codekönnen Benutzer Unterstützung vom Help-Desk-Team erhalten.
Help-Desk-Team
Using thecode:
- Wir können Fehler kategorisieren und eine Lösungstabelle erstellen, die Fehlercodes möglichen Lösungen zuordnet.
- Wir erleichtern die Kommunikation zwischen Benutzern und dem Help-Desk-Team.
- Das Help-Desk-Team kann entscheiden, ob es die Anfrage an das Entwicklungsteam weiterleiten soll oder nicht. Ihr fragt vielleicht, wie? Wenn
codeauf den Standard-Fehlercode gesetzt ist, sollte es weitergeleitet werden, oder?
Entwicklungs- oder Wartungsteam
- Mit der
idkönnen wir jeden Fehler eindeutig identifizieren und die Logs oder alle anderen damit zusammenhängenden Informationen finden und sie zum Debuggen verwenden.
Alles in allem glaube ich, dass unsere Benutzer jetzt zufrieden sein sollten, auch wenn ein Fehler auftritt. Wenn ihr das nicht glaubt oder Verbesserungsmöglichkeiten seht, lasst mich bitte eure Meinungen in den Kommentaren wissen. Danke!
Agent Hub
Gehen Sie über vereinzelte AI-Piloten hinaus und schaffen Sie eine sichere, skalierbare Grundlage für Agentic AI in Ihrer gesamten Organisation.
Zur Demo




