mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 22:35:42 +00:00
Classify text client command errors (#8422)
This commit is contained in:
@@ -18,6 +18,9 @@ object AuthorizationInterceptor {
|
||||
// Header for warmup/testing (simple username, no JWT required)
|
||||
private val WARMUP_USER_KEY: Key[String] =
|
||||
Key.of[String]("X-Warmup-User", Metadata.ASCII_STRING_MARSHALLER)
|
||||
|
||||
private val CLIENT_TYPE_KEY: Key[String] =
|
||||
Key.of[String]("X-Eagle-Client-Type", Metadata.ASCII_STRING_MARSHALLER)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,9 +81,17 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
|
||||
next: ServerCallHandler[ReqT, RespT]
|
||||
): ServerCall.Listener[ReqT] = {
|
||||
val methodName = call.getMethodDescriptor.getFullMethodName
|
||||
val clientType = Option(headers.get(CLIENT_TYPE_KEY)) match {
|
||||
case Some(AuthorizationUtils.TextClientType) => AuthorizationUtils.TextClientType
|
||||
case _ => "unknown"
|
||||
}
|
||||
|
||||
def withClientType(context: Context): Context =
|
||||
context.withValue(AuthorizationUtils.clientTypeCtxKey, clientType)
|
||||
|
||||
// Skip auth for public endpoints
|
||||
if publicEndpoints.contains(methodName) then next.startCall(call, headers)
|
||||
if publicEndpoints.contains(methodName) then
|
||||
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
|
||||
else if skipAuth then {
|
||||
// When skipAuth is enabled, auto-authenticate all requests as local-dev user
|
||||
val devContext = AuthorizationUtils.contextWithJwtClaims(
|
||||
@@ -89,7 +100,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
|
||||
isAdmin = true, // Give admin privileges for testing
|
||||
accessToken = ""
|
||||
)
|
||||
Contexts.interceptCall(devContext, call, headers, next)
|
||||
Contexts.interceptCall(withClientType(devContext), call, headers, next)
|
||||
} else {
|
||||
// Try JWT Bearer token first
|
||||
parseBearerToken(headers) match {
|
||||
@@ -136,7 +147,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
|
||||
}
|
||||
|
||||
effectiveContext match {
|
||||
case Some(context) => Contexts.interceptCall(context, call, headers, next)
|
||||
case Some(context) => Contexts.interceptCall(withClientType(context), call, headers, next)
|
||||
case None => new ServerCall.Listener[ReqT] {}
|
||||
}
|
||||
}
|
||||
@@ -152,13 +163,13 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
|
||||
isAdmin = false,
|
||||
accessToken = ""
|
||||
)
|
||||
Contexts.interceptCall(warmupContext, call, headers, next)
|
||||
Contexts.interceptCall(withClientType(warmupContext), call, headers, next)
|
||||
case Some(_) =>
|
||||
// X-Warmup-User from non-local network - ignore it, treat as unauthenticated
|
||||
next.startCall(call, headers)
|
||||
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
|
||||
case None =>
|
||||
// No auth at all - let endpoint handle unauthenticated request
|
||||
next.startCall(call, headers)
|
||||
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import io.grpc.Context
|
||||
import net.eagle0.eagle.UserId
|
||||
|
||||
object AuthorizationUtils {
|
||||
val TextClientType = "text"
|
||||
|
||||
// Username key - set by JWT auth for backwards compatibility with game management code
|
||||
val userNameCtxKey: Context.Key[String] = Context.key("userName")
|
||||
|
||||
@@ -12,6 +14,7 @@ object AuthorizationUtils {
|
||||
val displayNameCtxKey: Context.Key[String] = Context.key("displayName")
|
||||
val isAdminCtxKey: Context.Key[Boolean] = Context.keyWithDefault("isAdmin", false)
|
||||
val accessTokenCtxKey: Context.Key[String] = Context.key("accessToken")
|
||||
val clientTypeCtxKey: Context.Key[String] = Context.keyWithDefault("clientType", "unknown")
|
||||
|
||||
// Accessor for username (set by JWT auth)
|
||||
def userName: String = userNameCtxKey.get(Context.current)
|
||||
@@ -31,6 +34,8 @@ object AuthorizationUtils {
|
||||
|
||||
def accessToken: Option[String] = Option(accessTokenCtxKey.get(Context.current))
|
||||
|
||||
def clientType: String = clientTypeCtxKey.get(Context.current)
|
||||
|
||||
// Context builder for JWT auth
|
||||
def contextWithJwtClaims(
|
||||
userId: String,
|
||||
|
||||
@@ -315,6 +315,7 @@ class EagleServiceImpl(
|
||||
case UpdateStreamRequest.RequestDetails.PostCommandRequest(
|
||||
request
|
||||
) =>
|
||||
val clientType = AuthorizationUtils.clientType
|
||||
postCommand(request).map { postCommandResponse =>
|
||||
responseObserver.onNext(
|
||||
UpdateStreamResponse(
|
||||
@@ -333,10 +334,14 @@ class EagleServiceImpl(
|
||||
case e: Exception =>
|
||||
val commandDesc = describeCommand(request)
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Error processing command for ${responseObserver.userName} ($commandDesc): ${e.getMessage}"
|
||||
s"Command error clientType=$clientType user=${responseObserver.userName} ($commandDesc): ${e.getMessage}"
|
||||
)
|
||||
e.printStackTrace()
|
||||
Sentry.captureException(e)
|
||||
if EagleServiceImpl.shouldReportCommandException(clientType, e) then
|
||||
Sentry.withScope { scope =>
|
||||
scope.setTag("client_type", clientType)
|
||||
Sentry.captureException(e): Unit
|
||||
}
|
||||
responseObserver.onNext(
|
||||
UpdateStreamResponse(
|
||||
responseDetails = UpdateStreamResponse.ResponseDetails.PostCommandResponse(
|
||||
@@ -695,6 +700,9 @@ object EagleServiceImpl {
|
||||
.get("EAGLE_LOG_HEARTBEATS")
|
||||
.exists(value => EnabledEnvValues.contains(value.trim.toLowerCase))
|
||||
|
||||
private[service] def shouldReportCommandException(clientType: String, exception: Exception): Boolean =
|
||||
clientType != AuthorizationUtils.TextClientType || !exception.isInstanceOf[EagleClientException]
|
||||
|
||||
private[service] def tutorialStartPhaseForCreateGame(request: CreateGameRequest): Option[TutorialPhase] =
|
||||
if request.tutorialStartPhase != TutorialPhaseProto.TUTORIAL_PHASE_UNKNOWN then
|
||||
Some(TutorialPhaseConverter.fromProto(request.tutorialStartPhase))
|
||||
|
||||
@@ -33,6 +33,8 @@ object EagleTextClient {
|
||||
Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER)
|
||||
private val WarmupUserKey: Metadata.Key[String] =
|
||||
Metadata.Key.of("X-Warmup-User", Metadata.ASCII_STRING_MARSHALLER)
|
||||
private val ClientTypeKey: Metadata.Key[String] =
|
||||
Metadata.Key.of("X-Eagle-Client-Type", Metadata.ASCII_STRING_MARSHALLER)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
val config = Config.fromArgs(args.toVector)
|
||||
@@ -106,14 +108,13 @@ final class EagleTextClient(config: Config) {
|
||||
private val baseStub: EagleStub = EagleGrpc.stub(channel)
|
||||
private val stub: EagleStub = {
|
||||
val metadata = new Metadata()
|
||||
metadata.put(EagleTextClient.ClientTypeKey, "text")
|
||||
config.bearerToken.foreach(token =>
|
||||
metadata.put(EagleTextClient.AuthorizationKey, s"Bearer ${token.stripPrefix("Bearer ")}")
|
||||
)
|
||||
config.warmupUser.foreach(user => metadata.put(EagleTextClient.WarmupUserKey, user))
|
||||
|
||||
if config.bearerToken.isDefined || config.warmupUser.isDefined then
|
||||
baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
|
||||
else baseStub
|
||||
baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
|
||||
}
|
||||
|
||||
private val state = new ClientState
|
||||
|
||||
@@ -35,6 +35,7 @@ scala_test(
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tutorial_phase_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:tutorial_phase",
|
||||
"//src/main/scala/net/eagle0/eagle/service",
|
||||
],
|
||||
|
||||
@@ -2,12 +2,31 @@ package net.eagle0.eagle.service
|
||||
|
||||
import net.eagle0.eagle.api.eagle.CreateGameRequest
|
||||
import net.eagle0.eagle.common.tutorial_phase.TutorialPhase as TutorialPhaseProto
|
||||
import net.eagle0.eagle.library.EagleClientException
|
||||
import net.eagle0.eagle.model.state.TutorialPhase
|
||||
import org.scalatest.flatspec.AnyFlatSpec
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class EagleServiceImplTest extends AnyFlatSpec with Matchers {
|
||||
|
||||
"shouldReportCommandException" should "suppress expected command errors from the text client" in {
|
||||
EagleServiceImpl.shouldReportCommandException(
|
||||
"text",
|
||||
EagleClientException("invalid command")
|
||||
) shouldBe false
|
||||
}
|
||||
|
||||
it should "report unexpected errors from the text client and all errors from unmarked clients" in {
|
||||
EagleServiceImpl.shouldReportCommandException(
|
||||
"text",
|
||||
RuntimeException("unexpected failure")
|
||||
) shouldBe true
|
||||
EagleServiceImpl.shouldReportCommandException(
|
||||
"unknown",
|
||||
EagleClientException("invalid command")
|
||||
) shouldBe true
|
||||
}
|
||||
|
||||
"tutorialStartPhaseForCreateGame" should "start tutorial requests at the opening battle by default" in {
|
||||
EagleServiceImpl.tutorialStartPhaseForCreateGame(
|
||||
CreateGameRequest(isTutorial = true)
|
||||
|
||||
Reference in New Issue
Block a user