Admin SDK 오류 처리

Admin SDK 오류는 두 가지 카테고리로 나뉩니다.

  1. 프로그래밍 오류: 프로그래밍 및 구성 오류입니다. 사용자 애플리케이션입니다. 주로 SDK를 잘못 사용하기 때문에 발생합니다. (예: null 값을 허용하지 않는 메서드에 null 전달) 기타 구성 오류 (Firebase 프로젝트 또는 SDK 수준의 오류) 사용자 인증 정보, 잘못된 프로젝트 ID 문자열 등).
  2. API 오류: SDK 구현 내에서 발생하는 다양한 복구 가능한 오류를 포함하며, 모두 Firebase 백엔드 서비스에서 발생하는 오류와 기타 일시적인 RPC 호출 중 발생할 수 있는 시간 제한과 같은 오류를 방지합니다.

Admin SDK는 고유의 오류를 발생시켜 프로그래밍 오류 신호를 보냅니다. 확인할 수 있습니다

  • Java: IllegalArgumentException, NullPointerException의 인스턴스를 발생시킵니다. 또는 유사한 기본 제공 런타임 오류 유형을 표시합니다.
  • Python: ValueError, TypeError 또는 기타 기본 제공 오류 유형의 인스턴스를 발생시킵니다.
  • Go: 일반 오류를 반환합니다.
  • .NET: ArgumentException, ArgumentNullException 또는 유사한 기본 제공 오류 유형이 있습니다.

대부분의 경우 프로그래밍 오류를 명시적으로 처리해서는 안 됩니다. 대신 프로그래밍 오류를 완전히 방지하려면 코드와 구성을 수정해야 합니다. 다음 Java 스니펫을 고려해 보세요.

String uid = getUserInput();

UserRecord user = FirebaseAuth.getInstance().getUser(uid);

getUserInput() 메서드가 null 또는 빈 문자열을 반환하면 FirebaseAuth.getUser() API에서 IllegalArgumentException이 발생합니다. 대신 명시적으로 처리하는 경우에는 getUserInput() 메서드는 잘못된 UID 문자열을 반환하지 않습니다. 그렇지 않은 경우 다음과 같이 자체 코드에서 필요한 인수 확인을 구현하세요.

String uid = getUserInput();
if (Strings.isNullOrEmpty(uid)) {
    log.warn("UID must not be null or empty");
    return;
}

UserRecord user = FirebaseAuth.getInstance().getUser(uid);

프로그래밍 오류로 인해 다시 시도하지 않는 것이 원칙입니다. 빠른 실패(fail-fast) 허용 프로그래밍 오류에 대한 의미 체계가 최선의 조치 방침인 경우가 많으며 그 이유는 개발 중에 프로그래밍 버그와 구성 오류가 노출되어 즉시 수정할 수 있습니다 이러한 맥락에서 빠른 실패는 오류를 일으킬 수 있는 애플리케이션의 전역 오류 핸들러로 전파하거나 단순히 로깅만 할 수도 있습니다. 감사 목적(현재 실행 흐름 종료) (애플리케이션이 비정상 종료되지 않아야 함) 일반적으로 다음 오류를 따릅니다. 프로그래밍 언어 및 애플리케이션의 모범 사례를 프레임워크입니다 이것만으로도 이러한 종류의 이벤트를 올바르게 처리하는 데 충분할 때가 많습니다. 오류가 발생했습니다.

일반적으로 대부분의 오류 처리 작업은 API 오류가 표시됩니다. 이러한 오류 중 일부는 복구 가능합니다(예: 일부는 서비스가 진행되는 동안 예상되는 예를 들어 잘못되거나 만료된 ID 토큰을 감지하는 등의 정상적인 프로그램 실행 흐름에서 벗어날 수 있습니다. 이 가이드의 나머지 부분에서는 Admin SDK가 이러한 API 오류를 나타내는 방법을 설명합니다. 이를 처리하는 데 사용할 수 있는 다양한 옵션에 대해 알아보겠습니다.

API 오류의 구조

API 오류는 다음 구성요소로 구성됩니다.

  1. 오류 코드
  2. 오류 메시지
  3. 서비스 오류 코드 (선택사항)
  4. HTTP 응답 (선택사항)

모든 API 오류에는 오류 코드와 오류 메시지가 포함됩니다. 특정 API 오류에는 해당 API와 관련된 서비스 오류 코드도 포함됩니다. 확인할 수 있습니다 예를 들어 Firebase 인증에서 생성되는 몇 가지 오류 API에 Firebase 인증과 관련된 서비스 오류 코드가 포함되어 있습니다. 오류가 백엔드 서비스의 HTTP 오류 응답 결과, API 오류 에는 해당 HTTP 응답도 포함됩니다. 이는 포드의 상태를 원본 응답의 정확한 헤더와 내용 디버깅, 로깅 또는 보다 정교한 오류 처리 로직 구현

Node.js를 제외한 모든 Admin SDK 구현은 액세스할 수 있는 API를 제공합니다. API 오류의 위 구성요소를 알아봅니다.

언어별 오류 유형 및 API

자바

Java에서는 모든 API 오류가 FirebaseException 클래스를 확장합니다. 다음에서 액세스할 수 있습니다. 오류 코드, 오류 메시지, 이 기본 클래스의 HTTP 응답(선택사항)을 반환합니다.

public class FirebaseException extends Exception {
    @NonNull
    public ErrorCode getErrorCode() {
        // ...
    }

    @NonNull
    public String getMessage() {
        // ...
    }

    @Nullable
    public IncomingHttpResponse getHttpResponse() {
        // ...
    }
}

서비스 오류 코드를 노출하는 API는 오류의 API별 서브클래스를 FirebaseException 예를 들어 FirebaseAuth API의 모든 공개 메서드는 FirebaseAuthException의 인스턴스를 발생시키도록 선언됩니다. 다음에서 액세스할 수 있습니다. 이 파생 클래스의 서비스 오류 코드입니다.

public class FirebaseAuthException extends FirebaseException {
    @Nullable
    public AuthErrorCode getAuthErrorCode() {
        // ...
    }
}

Python

Python에서 모든 API 오류는 exceptions.FirebaseError를 확장합니다. 클래스에 대해 자세히 알아보세요. 오류 코드, 오류 메시지, 선택사항인 HTTP 이 기본 클래스의 응답을 반환합니다.

class FirebaseError(Exception):
    @property
    def code(self):
          # ...

    @property
    def message(self):
          # ...

    @property
    def http_response(self):
          # ...

또한 Python Admin SDK는 각 오류 코드에 대해 별도의 파생 클래스를 제공합니다. 이러한 오류를 플랫폼 오류 클래스라고 합니다.

class InvalidArgumentError(FirebaseError):
    # ...

class NotFoundError(FirebaseError):
    # ...

코드에서 FirebaseError를 포착하여 code를 확인할 수 있습니다. 플랫폼 오류 클래스에 관해 isinstance() 검사를 실행합니다. 또는 다음과 같이 작성할 수 있습니다. 특정 플랫폼 오류 유형을 직접 포착하는 코드가 있습니다. 후자의 접근 방식은 오류 처리 코드가 더 읽기 쉬워질 수 있습니다.

서비스 오류 코드를 노출하는 API는 플랫폼의 API별 서브클래스를 제공합니다. 오류 클래스입니다. 예를 들어 auth 모듈의 모든 공개 메서드에서 다음을 발생시킬 수 있습니다. API별 오류 유형(예: auth.UserNotFoundError, auth.ExpiredIdTokenError입니다.

class UserNotFoundError(exceptions.NotFoundError):
    # …

class ExpiredIdTokenError(exceptions.InvalidArgumentError):
    # ...

Go

Go Admin SDK는 일련의 애플리케이션이 포함된 errorutils 패키지를 제공합니다. 함수입니다.

package errorutils

func IsInvalidArgument(err error) bool {
    // ...
}

func IsNotFound(err error) bool {
    // ...
}

오류 메시지는 Error() 함수가 반환한 오류가 발생했습니다. 선택적 HTTP 응답은 errorutils.HTTPResponse() 함수: *http.Response를 반환합니다.

nil 또는 다른 오류 값을 오류 검사에 전달해도 안전합니다. 함수(errorutils 패키지 내) 입력 인수가true 실제로 문제의 오류 코드가 포함되어 있으며 모든 항목에 대해 false를 반환합니다. 그렇지 않은 경우 HTTPResponse() 함수도 동작이 비슷하다는 점만 다릅니다. false 대신 nil

서비스 오류 코드를 노출하는 API에서 API별 오류 검사를 제공 함수를 사용해야 합니다. 예를 들어 auth 패키지는 IsUserNotFound()IsExpiredIDTokenError() 함수를 제공합니다.

.NET

.NET에서 모든 API 오류는 FirebaseException를 확장합니다. 클래스에 대해 자세히 알아보세요. 다음에서 액세스할 수 있습니다. 플랫폼 오류 코드, 오류 메시지, 이 베이스의 선택적 HTTP 응답 클래스에 대해 자세히 알아보세요.

public class FirebaseException : Exception {

    public ErrorCode ErrorCode { get; }

    public String Message { get; }

    public HttpResponseMessage HttpResponse { get; }
}

서비스 오류 코드를 노출하는 API는 오류의 API별 서브클래스를 FirebaseException 예를 들어 FirebaseAuth API의 모든 공개 메서드는 FirebaseAuthException의 인스턴스를 발생시키도록 선언됩니다. 다음에서 액세스할 수 있습니다. 이 파생 클래스의 서비스 오류 코드입니다.

public class FirebaseAuthException : FirebaseException {

    public AuthErrorCode AuthErrorCode { get; }
}

플랫폼 오류 코드

오류 코드는 모든 Firebase 및 Google Cloud Platform 서비스에서 공통적으로 사용됩니다. 다음 표에는 가능한 모든 플랫폼 오류 코드가 요약되어 있습니다. 이것은 안정적인 목록이며, 장기간 변경되지 않을 것으로 예상됩니다.

INVALID_ARGUMENT 클라이언트가 잘못된 인수를 지정했습니다.
FAILED_PRECONDITION 비어 있지 않은 디렉터리를 삭제하는 등 현재 시스템 상태에서는 요청을 실행할 수 없습니다.
OUT_OF_RANGE 클라이언트가 잘못된 범위로 지정되었습니다.
UNAUTHENTICATED OAuth 토큰이 누락되었거나, 잘못되었거나, 만료되어 요청이 인증되지 않았습니다.
PERMISSION_DENIED 클라이언트에게 충분한 권한이 없습니다. 이 문제는 OAuth 토큰의 범위가 잘못되었거나, 클라이언트에 권한이 없거나, 클라이언트 프로젝트에 대해 API가 사용 설정되지 않았기 때문에 발생할 수 있습니다.
NOT_FOUND 지정된 리소스를 찾을 수 없거나, 허용 목록 추가와 같은 알려지지 않은 이유로 요청이 거부되었습니다.
갈등 읽기-수정-쓰기 충돌 같은 동시 실행 충돌이 발생했습니다. 일부 기존 서비스에서만 사용됩니다. 대부분의 서비스는 대신 ABORTED 또는 ALREADY_EXISTS를 사용합니다. 코드에서 처리할 작업을 확인하려면 서비스별 문서를 참조하세요.
ABORTED 읽기-수정-쓰기 충돌 같은 동시 실행 충돌이 발생했습니다.
ALREADY_EXISTS 클라이언트가 만들려고 했던 리소스가 이미 존재합니다.
RESOURCE_EXHAUSTED 리소스 할당량이 부족하거나 비율 제한에 도달했습니다.
CANCELLED 클라이언트에서 요청을 취소했습니다.
DATA_LOSS 복구할 수 없는 데이터 손실 또는 손상이 발생했습니다. 이때는 클라이언트가 사용자에게 오류를 보고해야 합니다.
알 수 없음 알 수 없는 서버 오류가 발생했습니다. 전형적인 서버 버그입니다.

이 오류 코드는 로컬 응답 파싱 (마샬링 해제) 오류와 쉽게 진단할 수 없는 기타 광범위한 하위 수준 I/O 오류에도 할당됩니다.

내부 내부 서버 오류입니다. 전형적인 서버 버그입니다.
UNAVAILABLE 서비스를 사용할 수 없습니다. 일반적으로 서버가 일시적으로 다운됩니다.

이 오류 코드는 로컬 네트워크 오류 (연결 거부, 호스트 경로 없음)에도 할당됩니다.

DEADLINE_EXCEEDED 요청 기한이 지났습니다. 이는 호출자가 대상 API의 기본 기한보다 짧은 기한을 설정하고 (즉, 요청된 기한이 서버가 요청을 처리하기에 충분하지 않음) 요청이 기한 내에 완료되지 않은 경우에만 발생합니다.

이 오류 코드는 로컬 연결 및 읽기 제한 시간에도 할당됩니다.

대부분의 API는 위 오류 코드의 하위 집합으로만 이어질 수 있습니다. 어떤 경우든 이러한 오류 코드를 명시적으로 처리하지 않을 것으로 예상되는 경우 모두 살펴봤습니다 대부분의 애플리케이션은 특정 오류 코드 1~2개, 나머지는 일반적이고 복구 불가능한 코드로 취급 있습니다

서비스별 오류 코드

Firebase 인증

CERTIFICATE_FETCH_FAILED JWT (ID 토큰 또는 세션 쿠키)를 인증하는 데 필요한 공개키 인증서를 가져올 수 없습니다.
EMAIL_ALREADY_EXISTS 입력한 이메일을 가진 사용자가 이미 있습니다.
EXPIRED_ID_TOKEN verifyIdToken()에 지정된 ID 토큰이 만료되었습니다.
만료된_세션_쿠키 verifySessionCookie()에 지정된 세션 쿠키가 만료되었습니다.
잘못된_DYNAMIC_LINK_DOMAIN 제공된 동적 링크 도메인이 구성되지 않았거나 현재 프로젝트에 승인되지 않았습니다. 이메일 작업 링크 API와 관련이 있습니다.
잘못된_ID_토큰 verifyIdToken()에 지정된 ID 토큰이 잘못되었습니다.
잘못된_SESSION_COOKIE verifySessionCookie()에 지정된 세션 쿠키가 잘못되었습니다.
PHONE_NUMBER_ALREADY_EXISTS번 입력한 전화번호를 가진 사용자가 이미 존재합니다.
REVOKED_ID_TOKEN(취소됨) verifyIdToken()에 지정된 ID 토큰이 취소됩니다.
취소_세션_쿠키 verifySessionCookie()에 지정된 세션 쿠키가 만료되었습니다.
UNAUTHORIZED_CONTINUE_URL 연결 URL의 도메인이 허용 목록에 포함되어 있지 않습니다. 이메일 작업 링크 API와 관련이 있습니다.
사용자를_찾을 수 없음 지정된 식별자에 대한 사용자 레코드가 없습니다.

Firebase Cloud Messaging

제3자_인증_오류 APN 인증서 또는 웹 푸시 인증 API 키가 잘못되었거나 누락되었습니다.
INVALID_ARGUMENT 요청에 지정된 하나 이상의 인수가 잘못되었습니다.
내부 내부 서버 오류입니다.
QUOTA_EXCEEDED개 메시지 타겟의 전송 한도를 초과했습니다.
잘못된 발신자_ID_NAME 인증된 발신자 ID가 등록 토큰의 발신자 ID와 다릅니다. 일반적으로 발신자와 대상 등록 토큰이 동일한 Firebase 프로젝트에 없다는 의미입니다.
UNAVAILABLE Cloud Messaging 서비스를 일시적으로 사용할 수 없습니다.
등록되지 않음 FCM에서 앱 인스턴스가 등록 취소되었습니다. 이는 일반적으로 사용된 기기 등록 토큰이 더 이상 유효하지 않으며 새 토큰을 사용해야 함을 의미합니다.

자동 재시도

Admin SDK가 특정 오류를 자동으로 다시 시도한 후 이러한 오류를 표시합니다. 제공할 수 있습니다. 일반적으로 다음 유형의 오류는 투명하게 재시도됩니다.

  • HTTP 503 (서비스를 사용할 수 없음) 응답으로 인해 발생한 모든 API 오류입니다.
  • HTTP 500 (내부 서버 오류) 응답으로 인해 일부 API 오류가 발생했습니다.
  • 대부분의 하위 수준 I/O 오류 (연결 거부, 연결 재설정 등)

SDK에서 위의 각 오류를 최대 5회 (원래 시도)까지 재시도합니다. + 4번의 재시도)을 제공합니다. 자체 재시도를 구현할 수 있습니다. 응용 프로그램 수준에서 메커니즘을 구현하지만 일반적으로 필요합니다.

재시도 지원

Admin SDK의 Go 및 .NET 구현은 HTTP Retry-After 헤더를 처리합니다. 즉, 오류 응답이 반환되고 백엔드 서버에 표준 Retry-After 헤더가 포함되어 있는 경우 SDK는 다음을 실행합니다. 재시도 시 지정된 대기 시간이 long입니다. Retry-After 헤더가 매우 긴 대기 시간을 나타내면 SDK가 재시도를 우회하고 적절한 API 오류를 발생시킵니다.

Python Admin SDK는 현재 Retry-After 헤더를 지원하지 않습니다. 단순한 지수 백오프만 지원합니다

API 오류 처리 예시

일반 오류 핸들러 구현

대부분의 경우 광범위한 오류를 포착하는 일반 오류 핸들러가 필요합니다. 사전 정의된 오류 범위를 사용하여 프로그램 흐름이 예기치 않게 종료되는 것을 API 오류입니다. 이러한 오류 핸들러는 일반적으로 감사 목적으로 오류를 로깅합니다. 또는 발생한 모든 API에 대해 다른 기본 오류 처리 루틴을 호출합니다. 오류가 발생했습니다. 다른 오류 코드나 오류의 원인이 될 수 있는 원인

자바

try {
  FirebaseToken token = FirebaseAuth.getInstance().verifyIdToken(idToken);
  performPrivilegedOperation(token.getUid());
} catch (FirebaseAuthException ex) {
  System.err.println("Failed to verify ID token: " + ex.getMessage());
}

Python

try:
  token = auth.verify_id_token(idToken)
  perform_privileged_pperation(token.uid)
except exceptions.FirebaseError as ex:
  print(f'Failed to verify ID token: {ex}')

Go

token, err := client.VerifyIDToken(ctx, idToken)
if err != nil {
  log.Printf("Failed to verify ID token: %v", err)
  return
}

performPrivilegedOperation(token)

.NET

try
{
  var token = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(idToken);
  PerformPrivilegedOperation(token.getUid());
}
catch (FirebaseAuthException ex) 
{
  Conole.WriteLine($"Failed to verify ID token: {ex.Message}");
}

오류 코드 확인

정확한 오류 코드를 검사하고 다양한 컨텍스트 인식 오류 처리 루틴을 사용할 수 있습니다. 다음 예에서 다음 기준에 따라 보다 구체적인 오류 메시지를 기록하는 오류 핸들러를 보유해야 합니다. 서비스 오류 코드입니다.

자바

try {
  FirebaseToken token = FirebaseAuth.getInstance().verifyIdToken(idToken);
  performPrivilegedOperation(token.getUid());
} catch (FirebaseAuthException ex) {
  if (ex.getAuthErrorCode() == AuthErrorCode.ID_TOKEN_EXPIRED) {
    System.err.println("ID token has expired");
  } else if (ex.getAuthErrorCode() == AuthErrorCode.ID_TOKEN_INVALID) {
    System.err.println("ID token is malformed or invalid");
  } else {
    System.err.println("Failed to verify ID token: " + ex.getMessage());
  }
}

Python

try:
  token = auth.verify_id_token(idToken)
  perform_privileged_operation(token.uid)
except auth.ExpiredIdTokenError:
  print('ID token has expired')
except auth.InvalidIdTokenError:
  print('ID token is malformed or invalid')
except exceptions.FirebaseError as ex:
  print(f'Failed to verify ID token: {ex}')

Go

token, err := client.VerifyIDToken(ctx, idToken)
if auth.IsIDTokenExpired(err) {
  log.Print("ID token has expired")
  return
}
if auth.IsIDTokenInvalid(err) {
  log.Print("ID token is malformed or invalid")
  return
}
if err != nil {
  log.Printf("Failed to verify ID token: %v", err)
  return
}

performPrivilegedOperation(token)

.NET

try
{
  var token = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(idToken);
  PerformPrivilegedOperation(token.getUid());
}
catch (FirebaseAuthException ex)
{
  if (ex.AuthErrorCode == AuthErrorCode.ExpiredIdToken)
  {
    Console.WriteLine("ID token has expired");
  }
  else if (ex.AuthErrorCode == AuthErrorCode.InvalidIdToken)
  {
    Console.WriteLine("ID token is malformed or invalid");
  }
  else
  {
    Conole.WriteLine($"Failed to verify ID token: {ex.Message}");
  }
}

다음은 최상위 오류 코드와 서비스 오류 코드를 모두 확인하는 또 다른 예입니다.

자바

try {
  FirebaseMessaging.getInstance().send(createMyMessage());
} catch (FirebaseMessagingException ex){
  if (ex.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
    System.err.println("App instance has been unregistered");
    removeTokenFromDatabase();
  } else if (ex.getErrorCode() == ErrorCode.Unavailable) {
    System.err.println("FCM service is temporarily unavailable");
    scheduleForRetryInAnHour();
  } else {
    System.err.println("Failed to send notification: " + ex.getMessage());
  }
}

Python

try:
  messaging.send(create_my_message())
except messaging.UnregisteredError:
  print('App instance has been unregistered')
  remove_token_from_database()
except exceptions.UnavailableError:
  print('FCM service is temporarily unavailable')
  schedule_for_retry_in_an_hour()
except exceptions.FirebaseError as ex:
  print(f'Failed to send notification: {ex}')

Go

_, err := client.Send(ctx, createMyMessage())
if messaging.IsUnregistered(err) {
  log.Print("App instance has been unregistered")
  removeTokenFromDatabase()
  return
}
if errorutils.IsUnavailable(err) {
  log.Print("FCM service is temporarily unavailable")
  scheduleForRetryInAnHour()
  return
}
if err != nil {
  log.Printf("Failed to send notification: %v", err)
  return
}

.NET

try
{
  await FirebaseMessaging.DefaultInstance.SendAsync(createMyMessage());
}
catch (FirebaseMessagingException ex)
{
  if (ex.MessagingErrorCode == MessagingErrorCode.UNREGISTERED)
  {
    Console.WriteLine("App instance has been unregistered");
    removeTokenFromDatabase();
  }
  else if (ex.ErrorCode == ErrorCode.Unavailable)
  {
    Console.WriteLine("FCM service is temporarily unavailable");
    scheduleForRetryInAnHour();
  }
  else
  {
    Console.WriteLine($"Failed to send notification: {ex.Message}");
  }
}

HTTP 응답 액세스

드물지만 HTTP 오류 응답을 검사하거나 백엔드 서비스에 대한 오류 처리 작업을 수행합니다. Admin SDK 이러한 오류 응답의 헤더와 콘텐츠를 모두 노출합니다. 응답 콘텐츠는 일반적으로 문자열이나 원시 바이트 시퀀스로 반환되며 모든 타겟 형식으로 파싱됩니다.

자바

try {
  FirebaseMessaging.getInstance().send(createMyMessage());
} catch (FirebaseMessagingException ex){
  IncomingHttpResponse response = ex.getHttpResponse();
  if (response != null) {
    System.err.println("FCM service responded with HTTP " + response.getStatusCode());

    Map<String, Object> headers = response.getHeaders();
    for (Map.Entry<String, Object> entry : headers.entrySet()) {
      System.err.println(">>> " + entry.getKey() + ": " + entry.getValue());
    }

    System.err.println(">>>");
    System.err.println(">>> " + response.getContent());
  }
}

Python

try:
  messaging.send(create_my_message())
except exceptions.FirebaseError as ex:
  response = ex.http_response
  if response is not None:
    print(f'FCM service responded with HTTP {response.status_code}')

    for key, value in response.headers.items():
      print(f'>>> {key}: {value}')

    print('>>>')
    print(f'>>> {response.content}')

Go

_, err := client.Send(ctx, createMyMessage())
if resp := errorutils.HTTPResponse(err); resp != nil {
  log.Printf("FCM service responded with HTTP %d", resp.StatusCode)

  for key, value := range resp.Header {
      log.Printf(">>> %s: %v", key, value)
  }

  defer resp.Body.Close()
  b, _ := ioutil.ReadAll(resp.Body)
  log.Print(">>>")
  log.Printf(">>> %s", string(b))

  return
}

.NET

try
{
  await FirebaseMessaging.DefaultInstance.SendAsync(createMyMessage());
}
catch (FirebaseMessagingException ex)
{
  var response = ex.HttpResponse
  if response != null
  {
    Console.WriteLine($"FCM service responded with HTTP { response.StatusCode}");

    var headers = response.Headers;
    for (var entry in response.Headers)
    {
      Console.WriteLine($">>> {entry.Key}: {entry.Value}");
    }

    var body = await response.Content.ReadAsString();
    Console.WriteLine(">>>");
    Console.WriteLine($">>> {body}");
  }
}