Skip to content

Commit 7cb9800

Browse files
authored
[ETCM-48] Add json rpc http healthcheck (#713)
1 parent 6a394f4 commit 7cb9800

14 files changed

+316
-67
lines changed

insomnia_workspace.json

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"_type": "export",
33
"__export_format": 4,
4-
"__export_date": "2020-09-28T17:05:07.127Z",
4+
"__export_date": "2020-09-30T20:01:40.792Z",
55
"__export_source": "insomnia.desktop.app:v2020.4.1",
66
"resources": [
77
{
@@ -272,6 +272,35 @@
272272
"metaSortKey": -1553869483792,
273273
"_type": "request_group"
274274
},
275+
{
276+
"_id": "req_8c4ccaa3552544a4b61bc33cc9fa547c",
277+
"parentId": "fld_f75b249a780c4b5e97a0a2980ad1d4b8",
278+
"modified": 1580405661758,
279+
"created": 1580405461883,
280+
"url": "{{ node_url }}/healthcheck",
281+
"name": "healthcheck node",
282+
"description": "",
283+
"method": "GET",
284+
"body": {},
285+
"parameters": [],
286+
"headers": [
287+
{
288+
"id": "pair_088edc31f5e04f20a16b465a673871bb",
289+
"name": "Cache-Control",
290+
"value": "no-cache"
291+
}
292+
],
293+
"authentication": {},
294+
"metaSortKey": -1552939150156.3438,
295+
"isPrivate": false,
296+
"settingStoreCookies": true,
297+
"settingSendCookies": true,
298+
"settingDisableRenderRequestBody": false,
299+
"settingEncodeUrl": true,
300+
"settingRebuildPath": true,
301+
"settingFollowRedirects": "global",
302+
"_type": "request"
303+
},
275304
{
276305
"_id": "req_b60c1a4f9d604d868910f967c6a070d7",
277306
"parentId": "fld_a06eb77e183c4727800eb7dc43ceabe1",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
import scala.concurrent.{ExecutionContext, Future}
4+
import scala.util.{Failure, Success}
5+
6+
/**
7+
* Represents a health check, runs it and interprets the outcome.
8+
* The outcome can be either a normal result, an application error, or
9+
* an (unexpected) exception.
10+
*
11+
* @param description An one-word description of the health check.
12+
* @param f The function that runs the health check.
13+
* @param mapResultToError A function that interprets the result.
14+
* @param mapErrorToError A function that interprets the application error.
15+
* @param mapExceptionToError A function that interprets the (unexpected) exception.
16+
* @tparam Error The type of the application error.
17+
* @tparam Result The type of the actual value expected by normal termination of `f`.
18+
*/
19+
case class Healthcheck[Error, Result](
20+
description: String,
21+
f: () Future[Either[Error, Result]],
22+
mapResultToError: Result Option[String] = (_: Result) None,
23+
mapErrorToError: Error Option[String] = (error: Error) Some(String.valueOf(error)),
24+
mapExceptionToError: Throwable Option[String] = (t: Throwable) Some(String.valueOf(t))
25+
) {
26+
27+
def apply()(implicit ec: ExecutionContext): Future[HealthcheckResult] = {
28+
f().transform {
29+
case Success(Left(error))
30+
Success(HealthcheckResult(description, mapErrorToError(error)))
31+
case Success(Right(result))
32+
Success(HealthcheckResult(description, mapResultToError(result)))
33+
case Failure(t)
34+
Success(HealthcheckResult(description, mapExceptionToError(t)))
35+
}
36+
}
37+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
final case class HealthcheckResponse(checks: List[HealthcheckResult]) {
4+
lazy val isOK: Boolean = checks.forall(_.isOK)
5+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
final case class HealthcheckResult private (description: String, status: String, error: Option[String]) {
4+
assert(
5+
status == HealthcheckStatus.OK && error.isEmpty || status == HealthcheckStatus.ERROR && error.isDefined
6+
)
7+
8+
def isOK: Boolean = status == HealthcheckStatus.OK
9+
}
10+
11+
object HealthcheckResult {
12+
def apply(description: String, error: Option[String]): HealthcheckResult =
13+
new HealthcheckResult(
14+
description = description,
15+
status = error.fold(HealthcheckStatus.OK)(_ HealthcheckStatus.ERROR),
16+
error = error
17+
)
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
object HealthcheckStatus {
4+
final val OK = "OK"
5+
final val ERROR = "ERROR"
6+
}

src/main/scala/io/iohk/ethereum/jsonrpc/JsonRpcControllerMetrics.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ case object JsonRpcControllerMetrics extends MetricsContainer {
1313
final val MethodsSuccessCounter = metrics.counter("json.rpc.methods.success.counter")
1414
final val MethodsExceptionCounter = metrics.counter("json.rpc.methods.exception.counter")
1515
final val MethodsErrorCounter = metrics.counter("json.rpc.methods.error.counter")
16+
17+
final val HealhcheckErrorCounter = metrics.counter("json.rpc.healthcheck.error.counter")
1618
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.HealthcheckResponse
4+
5+
import scala.concurrent.Future
6+
import scala.util.{Failure, Success}
7+
import scala.concurrent.ExecutionContext.Implicits.global
8+
9+
trait JsonRpcHealthChecker {
10+
def healthCheck(): Future[HealthcheckResponse]
11+
12+
def handleResponse(responseF: Future[HealthcheckResponse]): Future[HealthcheckResponse] = {
13+
responseF.andThen {
14+
case Success(response) if (!response.isOK) =>
15+
JsonRpcControllerMetrics.HealhcheckErrorCounter.increment()
16+
case Failure(t) =>
17+
JsonRpcControllerMetrics.HealhcheckErrorCounter.increment()
18+
}
19+
}
20+
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.Healthcheck
4+
5+
object JsonRpcHealthcheck {
6+
type T[R] = Healthcheck[JsonRpcError, R]
7+
8+
def apply[R](description: String, f: () ServiceResponse[R]): T[R] = Healthcheck(description, f)
9+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.HealthcheckResponse
4+
import io.iohk.ethereum.jsonrpc.EthService._
5+
import io.iohk.ethereum.jsonrpc.NetService._
6+
7+
import scala.concurrent.ExecutionContext.Implicits.global
8+
import scala.concurrent.Future
9+
10+
class NodeJsonRpcHealthChecker(
11+
netService: NetService,
12+
ethService: EthService
13+
) extends JsonRpcHealthChecker {
14+
15+
protected def mainService: String = "node health"
16+
17+
final val listeningHC = JsonRpcHealthcheck("listening", () netService.listening(NetService.ListeningRequest()))
18+
final val peerCountHC = JsonRpcHealthcheck("peerCount", () netService.peerCount(PeerCountRequest()))
19+
final val earliestBlockHC = JsonRpcHealthcheck(
20+
"earliestBlock",
21+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Earliest, true))
22+
)
23+
final val latestBlockHC = JsonRpcHealthcheck(
24+
"latestBlock",
25+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Latest, true))
26+
)
27+
final val pendingBlockHC = JsonRpcHealthcheck(
28+
"pendingBlock",
29+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Pending, true))
30+
)
31+
32+
override def healthCheck(): Future[HealthcheckResponse] = {
33+
val listeningF = listeningHC()
34+
val peerCountF = peerCountHC()
35+
val earliestBlockF = earliestBlockHC()
36+
val latestBlockF = latestBlockHC()
37+
val pendingBlockF = pendingBlockHC()
38+
39+
val allChecksF = List(listeningF, peerCountF, earliestBlockF, latestBlockF, pendingBlockF)
40+
val responseF = Future.sequence(allChecksF).map(HealthcheckResponse)
41+
42+
handleResponse(responseF)
43+
}
44+
}

src/main/scala/io/iohk/ethereum/jsonrpc/server/http/BasicJsonRpcHttpServer.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import io.iohk.ethereum.utils.Logger
99
import scala.concurrent.ExecutionContext.Implicits.global
1010
import scala.util.{Failure, Success}
1111

12-
class BasicJsonRpcHttpServer(val jsonRpcController: JsonRpcController, config: JsonRpcHttpServerConfig)
13-
(implicit val actorSystem: ActorSystem)
14-
extends JsonRpcHttpServer with Logger {
12+
class BasicJsonRpcHttpServer(
13+
val jsonRpcController: JsonRpcController,
14+
val jsonRpcHealthChecker: JsonRpcHealthChecker,
15+
config: JsonRpcHttpServerConfig
16+
)(implicit val actorSystem: ActorSystem)
17+
extends JsonRpcHttpServer
18+
with Logger {
1519

1620
def run(): Unit = {
1721
val bindingResultF = Http(actorSystem).newServerAt(config.interface, config.port).bind(route)

src/main/scala/io/iohk/ethereum/jsonrpc/server/http/JsonRpcHttpServer.scala

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package io.iohk.ethereum.jsonrpc.server.http
22

33
import akka.actor.ActorSystem
4-
import akka.http.scaladsl.model.StatusCodes
4+
import akka.http.scaladsl.model._
55
import akka.http.scaladsl.server.Directives._
6-
import akka.http.scaladsl.server.{MalformedRequestContentRejection, RejectionHandler, Route}
6+
import akka.http.scaladsl.server._
77
import ch.megard.akka.http.cors.javadsl.CorsRejection
88
import ch.megard.akka.http.cors.scaladsl.CorsDirectives._
99
import ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher
1010
import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings
1111
import de.heikoseeberger.akkahttpjson4s.Json4sSupport
12-
import io.iohk.ethereum.jsonrpc.{JsonRpcController, JsonRpcErrors, JsonRpcRequest, JsonRpcResponse}
12+
import io.iohk.ethereum.jsonrpc._
1313
import io.iohk.ethereum.utils.{ConfigUtils, Logger}
1414
import java.security.SecureRandom
1515
import org.json4s.JsonAST.JInt
@@ -20,6 +20,7 @@ import scala.util.Try
2020

2121
trait JsonRpcHttpServer extends Json4sSupport {
2222
val jsonRpcController: JsonRpcController
23+
val jsonRpcHealthChecker: JsonRpcHealthChecker
2324

2425
implicit val serialization = native.Serialization
2526

@@ -32,7 +33,8 @@ trait JsonRpcHttpServer extends Json4sSupport {
3233
.withAllowedOrigins(corsAllowedOrigins)
3334

3435
implicit def myRejectionHandler: RejectionHandler =
35-
RejectionHandler.newBuilder()
36+
RejectionHandler
37+
.newBuilder()
3638
.handle {
3739
case _: MalformedRequestContentRejection =>
3840
complete((StatusCodes.BadRequest, JsonRpcResponse("2.0", None, Some(JsonRpcErrors.ParseError), JInt(0))))
@@ -42,7 +44,9 @@ trait JsonRpcHttpServer extends Json4sSupport {
4244
.result()
4345

4446
val route: Route = cors(corsSettings) {
45-
(pathEndOrSingleSlash & post) {
47+
(path("healthcheck") & pathEndOrSingleSlash & get) {
48+
handleHealthcheck()
49+
} ~ (pathEndOrSingleSlash & post) {
4650
entity(as[JsonRpcRequest]) { request =>
4751
handleRequest(request)
4852
} ~ entity(as[Seq[JsonRpcRequest]]) { request =>
@@ -56,6 +60,24 @@ trait JsonRpcHttpServer extends Json4sSupport {
5660
*/
5761
def run(): Unit
5862

63+
private def handleHealthcheck(): StandardRoute = {
64+
val responseF = jsonRpcHealthChecker.healthCheck()
65+
val httpResponseF =
66+
responseF.map {
67+
case response if response.isOK
68+
HttpResponse(
69+
status = StatusCodes.OK,
70+
entity = HttpEntity(ContentTypes.`application/json`, serialization.writePretty(response))
71+
)
72+
case response
73+
HttpResponse(
74+
status = StatusCodes.InternalServerError,
75+
entity = HttpEntity(ContentTypes.`application/json`, serialization.writePretty(response))
76+
)
77+
}
78+
complete(httpResponseF)
79+
}
80+
5981
private def handleRequest(request: JsonRpcRequest) = {
6082
complete(jsonRpcController.handleRequest(request))
6183
}
@@ -67,12 +89,18 @@ trait JsonRpcHttpServer extends Json4sSupport {
6789

6890
object JsonRpcHttpServer extends Logger {
6991

70-
def apply(jsonRpcController: JsonRpcController, config: JsonRpcHttpServerConfig, secureRandom: SecureRandom)
71-
(implicit actorSystem: ActorSystem): Either[String, JsonRpcHttpServer] = config.mode match {
72-
case "http" => Right(new BasicJsonRpcHttpServer(jsonRpcController, config)(actorSystem))
73-
case "https" => Right(new JsonRpcHttpsServer(jsonRpcController, config, secureRandom)(actorSystem))
74-
case _ => Left(s"Cannot start JSON RPC server: Invalid mode ${config.mode} selected")
75-
}
92+
def apply(
93+
jsonRpcController: JsonRpcController,
94+
jsonRpcHealthchecker: JsonRpcHealthChecker,
95+
config: JsonRpcHttpServerConfig,
96+
secureRandom: SecureRandom
97+
)(implicit actorSystem: ActorSystem): Either[String, JsonRpcHttpServer] =
98+
config.mode match {
99+
case "http" => Right(new BasicJsonRpcHttpServer(jsonRpcController, jsonRpcHealthchecker, config)(actorSystem))
100+
case "https" =>
101+
Right(new JsonRpcHttpsServer(jsonRpcController, jsonRpcHealthchecker, config, secureRandom)(actorSystem))
102+
case _ => Left(s"Cannot start JSON RPC server: Invalid mode ${config.mode} selected")
103+
}
76104

77105
trait JsonRpcHttpServerConfig {
78106
val mode: String
@@ -99,9 +127,15 @@ object JsonRpcHttpServer extends Logger {
99127

100128
override val corsAllowedOrigins = ConfigUtils.parseCorsAllowedOrigins(rpcHttpConfig, "cors-allowed-origins")
101129

102-
override val certificateKeyStorePath: Option[String] = Try(rpcHttpConfig.getString("certificate-keystore-path")).toOption
103-
override val certificateKeyStoreType: Option[String] = Try(rpcHttpConfig.getString("certificate-keystore-type")).toOption
104-
override val certificatePasswordFile: Option[String] = Try(rpcHttpConfig.getString("certificate-password-file")).toOption
130+
override val certificateKeyStorePath: Option[String] = Try(
131+
rpcHttpConfig.getString("certificate-keystore-path")
132+
).toOption
133+
override val certificateKeyStoreType: Option[String] = Try(
134+
rpcHttpConfig.getString("certificate-keystore-type")
135+
).toOption
136+
override val certificatePasswordFile: Option[String] = Try(
137+
rpcHttpConfig.getString("certificate-password-file")
138+
).toOption
105139
}
106140
}
107141
}

0 commit comments

Comments
 (0)