Skip to content

Instantly share code, notes, and snippets.

@rponte
Last active June 29, 2022 00:21
Show Gist options
  • Save rponte/949d947ac3c38aa7181929c41ee56c05 to your computer and use it in GitHub Desktop.
Save rponte/949d947ac3c38aa7181929c41ee56c05 to your computer and use it in GitHub Desktop.
Micronaut: Implementing a gRPC Server Interceptor for exception handling
package br.com.zup.edu.shared.grpc
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener
import io.grpc.Metadata
import io.grpc.ServerCall
import io.grpc.ServerCallHandler
import io.grpc.ServerInterceptor
import org.slf4j.LoggerFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* TIP1: This interceptor has been tested with gRPC-Java, maybe it doesn't work with gRPC-Kotlin
* TIP2: I'm not sure if this interceptor works well with all kind of gRPC-flows, like client and/or server streaming
* TIP3: I think that implementing this interceptor via AOP would be better because we don't have to worry about the gRPC life-cycle
*/
@Singleton
class ExceptionHandlerGrpcServerInterceptor(@Inject val resolver: ExceptionHandlerResolver) : ServerInterceptor {
private val logger = LoggerFactory.getLogger(ExceptionHandlerGrpcServerInterceptor::class.java)
override fun <ReqT : Any, RespT : Any> interceptCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>,
): ServerCall.Listener<ReqT> {
fun handleException(call: ServerCall<ReqT, RespT>, e: Exception) {
logger.error("Handling exception $e while processing the call: ${call.methodDescriptor.fullMethodName}")
val handler = resolver.resolve(e)
val translatedStatus = handler.handle(e)
call.close(translatedStatus.status, translatedStatus.metadata)
}
val listener: ServerCall.Listener<ReqT> = try {
next.startCall(call, headers)
} catch (ex: Exception) {
handleException(call, ex)
throw ex
}
return object : SimpleForwardingServerCallListener<ReqT>(listener) {
// No point in overriding onCancel and onComplete; it's already too late
override fun onHalfClose() {
try {
super.onHalfClose()
} catch (ex: Exception) {
handleException(call, ex)
throw ex
}
}
override fun onReady() {
try {
super.onReady()
} catch (ex: Exception) {
handleException(call, ex)
throw ex
}
}
}
}
}
package br.com.zup.edu.shared.grpc
import br.com.zup.edu.shared.grpc.handlers.DefaultExceptionHandler
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ExceptionHandlerResolver(
@Inject private val handlers: List<ExceptionHandler<Exception>>,
) {
private var defaultHandler: ExceptionHandler<Exception> = DefaultExceptionHandler()
/**
* We can replace the default exception handler through this constructor
* https://docs.micronaut.io/latest/guide/index.html#replaces
*/
constructor(handlers: List<ExceptionHandler<Exception>>, defaultHandler: ExceptionHandler<Exception>) : this(handlers) {
this.defaultHandler = defaultHandler
}
fun resolve(e: Exception): ExceptionHandler<Exception> {
val foundHandlers = handlers.filter { h -> h.supports(e) }
if (foundHandlers.size > 1)
throw IllegalStateException("Too many handlers supporting the same exception '${e.javaClass.name}': $foundHandlers")
return foundHandlers.firstOrNull() ?: defaultHandler
}
}
package br.com.zup.edu.shared.grpc
import io.grpc.Metadata
import io.grpc.Status
import io.grpc.StatusRuntimeException
import io.grpc.protobuf.StatusProto
interface ExceptionHandler<E : Exception> {
/**
* Handles exception and maps it to StatusWithDetails
*/
fun handle(e: E): StatusWithDetails
/**
* Verifies whether this instance can handle the specified exception or not
*/
fun supports(e: Exception): Boolean
/**
* Simple wrapper for Status and Metadata (trailers)
*/
data class StatusWithDetails(val status: Status, val metadata: Metadata = Metadata()) {
constructor(se: StatusRuntimeException): this(se.status, se.trailers ?: Metadata())
constructor(sp: com.google.rpc.Status): this(StatusProto.toStatusRuntimeException(sp))
fun asRuntimeException(): StatusRuntimeException {
return status.asRuntimeException(metadata)
}
}
}
package br.com.zup.edu.shared.grpc.handlers
import br.com.zup.edu.shared.grpc.ExceptionHandler
import br.com.zup.edu.shared.grpc.ExceptionHandler.StatusWithDetails
import io.grpc.Status
/**
* By design, this class must NOT be managed by Micronaut
*/
class DefaultExceptionHandler : ExceptionHandler<Exception> {
override fun handle(e: Exception): StatusWithDetails {
val status = when (e) {
is IllegalArgumentException -> Status.INVALID_ARGUMENT.withDescription(e.message)
is IllegalStateException -> Status.FAILED_PRECONDITION.withDescription(e.message)
else -> Status.UNKNOWN
}
return StatusWithDetails(status.withCause(e))
}
override fun supports(e: Exception): Boolean {
return true
}
}
package br.com.zup.edu.shared.grpc.handlers
import br.com.zup.edu.shared.grpc.ExceptionHandler
import br.com.zup.edu.shared.grpc.ExceptionHandler.StatusWithDetails
import com.google.protobuf.Any
import com.google.rpc.BadRequest
import com.google.rpc.Code
import javax.inject.Singleton
import javax.validation.ConstraintViolationException
/**
* Handles the Bean Validation errors adding theirs violations into request trailers (metadata)
*/
@Singleton
class ConstraintViolationExceptionHandler : ExceptionHandler<ConstraintViolationException> {
override fun handle(e: ConstraintViolationException): StatusWithDetails {
val details = BadRequest.newBuilder()
.addAllFieldViolations(e.constraintViolations.map {
BadRequest.FieldViolation.newBuilder()
.setField(it.propertyPath.last().name ?: "?? key ??") // still thinking how to solve this case
.setDescription(it.message)
.build()
})
.build()
val statusProto = com.google.rpc.Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT_VALUE)
.setMessage("Request with invalid data")
.addDetails(Any.pack(details))
.build()
return StatusWithDetails(statusProto)
}
override fun supports(e: Exception): Boolean {
return e is ConstraintViolationException
}
}
package br.com.zup.edu.shared.grpc.handlers
import br.com.zup.edu.shared.grpc.ExceptionHandler
import br.com.zup.edu.shared.grpc.ExceptionHandler.StatusWithDetails
import io.grpc.Status
import io.micronaut.context.MessageSource
import io.micronaut.context.MessageSource.MessageContext
import org.hibernate.exception.ConstraintViolationException
import javax.inject.Inject
import javax.inject.Singleton
/**
* The idea of this handler is to deal with database constraints errors, like unique or FK constraints for example
*/
@Singleton
class DataIntegrityExceptionHandler(@Inject var messageSource: MessageSource) : ExceptionHandler<ConstraintViolationException> {
override fun handle(e: ConstraintViolationException): StatusWithDetails {
val constraintName = e.constraintName
if (constraintName.isNullOrBlank()) {
return internalServerError(e)
}
val message = messageSource.getMessage("data.integrity.error.$constraintName", MessageContext.DEFAULT)
return message
.map { alreadyExistsError(it, e) } // TODO: dealing with many types of constraint errors
.orElse(internalServerError(e))
}
override fun supports(e: Exception): Boolean {
return e is ConstraintViolationException
}
private fun alreadyExistsError(message: String?, e: ConstraintViolationException) =
StatusWithDetails(Status.ALREADY_EXISTS
.withDescription(message)
.withCause(e))
private fun internalServerError(e: ConstraintViolationException) =
StatusWithDetails(Status.INTERNAL
.withDescription("Unexpected internal server error")
.withCause(e))
}
package br.com.zup.edu.shared.grpc.handlers
import br.com.zup.edu.pix.ChavePixNaoEncontradaException
import br.com.zup.edu.shared.grpc.ExceptionHandler
import br.com.zup.edu.shared.grpc.ExceptionHandler.*
import io.grpc.Status
import javax.inject.Singleton
@Singleton
class ChavePixNaoEncontradaExceptionHandler : ExceptionHandler<ChavePixNaoEncontradaException> {
override fun handle(e: ChavePixNaoEncontradaException): StatusWithDetails {
return StatusWithDetails(Status.NOT_FOUND
.withDescription(e.message)
.withCause(e))
}
override fun supports(e: Exception): Boolean {
return e is ChavePixNaoEncontradaException
}
}
package br.com.zup.edu.shared.grpc.handlers
import br.com.zup.edu.pix.ChavePixExistenteException
import br.com.zup.edu.shared.grpc.ExceptionHandler
import br.com.zup.edu.shared.grpc.ExceptionHandler.StatusWithDetails
import io.grpc.Status
import javax.inject.Singleton
@Singleton
class ChavePixExistenteExceptionHandler : ExceptionHandler<ChavePixExistenteException> {
override fun handle(e: ChavePixExistenteException): StatusWithDetails {
return StatusWithDetails(Status.ALREADY_EXISTS
.withDescription(e.message)
.withCause(e))
}
override fun supports(e: Exception): Boolean {
return e is ChavePixExistenteException
}
}
package br.com.zup.edu.conf
import io.micronaut.context.MessageSource
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import io.micronaut.context.i18n.ResourceBundleMessageSource
import io.micronaut.runtime.context.CompositeMessageSource
import javax.inject.Singleton
@Factory
class I18nConfig {
@Bean
@Singleton
fun messageSource(): MessageSource {
return CompositeMessageSource(listOf(
ResourceBundleMessageSource("messages") // messages.properties
))
}
}
data.integrity.error.uk_author_email=author already exists
@Entity
@Table(uniqueConstraints = [UniqueConstraint(
name = "uk_author_email", // you must define the constraint name properly
columnNames = ["email"]
)])
class Author(
// other fields
@Column(unique = true, nullable = false)
val email: String,
)