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);

원칙적으로 프로그래밍 오류에 대해서는 절대로 재시도하지 마십시오. 프로그래밍 오류에 대해 빠른 실패 의미론을 허용하는 것은 개발 중에 프로그래밍 버그와 구성 오류를 노출시켜 즉시 수정할 수 있기 때문에 종종 최선의 조치입니다. 이 맥락에서 빠른 실패란 오류가 애플리케이션의 전역 오류 처리기로 전파되도록 허용하거나 감사 목적으로 기록하고 현재 실행 흐름을 종료하는 것을 의미할 수 있습니다(애플리케이션이 충돌해서는 안 됨). 일반적으로 프로그래밍 언어 및 애플리케이션 프레임워크의 오류 처리 모범 사례를 따르세요. 이것만으로도 이러한 종류의 오류를 올바르게 처리하는 데 충분할 때가 많습니다.

일반적으로 오류 처리 노력의 대부분은 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는 FirebaseException 의 API별 하위 클래스를 제공합니다. 예를 들어 FirebaseAuth API의 모든 공개 메소드는 FirebaseAuthException 인스턴스를 발생시키도록 선언됩니다. 이 파생 클래스에서 서비스 오류 코드에 액세스할 수 있습니다.

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

파이썬

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 모듈의 모든 공개 메소드는 auth.UserNotFoundErrorauth.ExpiredIdTokenError 와 같은 API 관련 오류 유형을 발생시킬 수 있습니다.

class UserNotFoundError(exceptions.NotFoundError):
    # …

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

가다

Go Admin SDK는 오류 코드를 테스트할 수 있는 일련의 기능이 포함된 errorutils 패키지를 제공합니다.

package errorutils

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

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

오류 메시지는 단순히 오류의 Error() 함수가 반환한 문자열입니다. 선택적 HTTP 응답은 *http.Response 를 반환하는 errorutils.HTTPResponse() 함수를 호출하여 액세스할 수 있습니다.

errorutils 패키지의 오류 검사 기능에 nil 또는 기타 오류 값을 전달하는 것이 안전합니다. 입력 인수에 실제로 문제의 오류 코드가 포함되어 있으면 true 반환하고, 다른 모든 경우에는 false 반환합니다. HTTPResponse() 함수는 false 대신 nil 반환한다는 점을 제외하면 유사한 동작을 합니다.

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

.그물

.NET에서는 모든 API 오류가 FirebaseException 클래스를 확장합니다. 이 기본 클래스에서 플랫폼 오류 코드, 오류 메시지 및 선택적 HTTP 응답에 액세스할 수 있습니다.

public class FirebaseException : Exception {

    public ErrorCode ErrorCode { get; }

    public String Message { get; }

    public HttpResponseMessage HttpResponse { get; }
}

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

public class FirebaseAuthException : FirebaseException {

    public AuthErrorCode AuthErrorCode { get; }
}

플랫폼 오류 코드

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

INVALID_ARGUMENT 클라이언트가 잘못된 인수를 지정했습니다.
FAILED_PRECONDITION 비어 있지 않은 디렉터리 삭제 등 현재 시스템 상태에서는 요청을 실행할 수 없습니다.
OUT_OF_RANGE 클라이언트가 잘못된 범위를 지정했습니다.
인증되지 않음 OAuth 토큰이 누락되었거나 유효하지 않거나 만료되어 요청이 인증되지 않았습니다.
허가_거부됨 클라이언트에 충분한 권한이 없습니다. 이는 OAuth 토큰에 올바른 범위가 없거나, 클라이언트에 권한이 없거나, 클라이언트 프로젝트에 대해 API가 활성화되지 않았기 때문에 발생할 수 있습니다.
NOT_FOUND 지정된 리소스를 찾을 수 없거나, 화이트리스트 등 공개되지 않은 사유로 인해 요청이 거부되었습니다.
갈등 읽기-수정-쓰기 충돌과 같은 동시성 충돌. 일부 레거시 서비스에서만 사용됩니다. 대부분의 서비스는 이 대신 ABORTED 또는 ALREADY_EXISTS를 사용합니다. 코드에서 처리할 서비스를 확인하려면 서비스별 설명서를 참조하세요.
중단됨 읽기-수정-쓰기 충돌과 같은 동시성 충돌.
이미 존재 함 클라이언트가 생성하려고 시도한 리소스가 이미 존재합니다.
자원이 소진되었습니다. 리소스 할당량이 부족하거나 속도 제한에 도달했습니다.
취소 된 클라이언트가 요청을 취소했습니다.
데이터_손실 복구할 수 없는 데이터 손실 또는 데이터 손상. 클라이언트는 사용자에게 오류를 보고해야 합니다.
알려지지 않은 알 수 없는 서버 오류입니다. 일반적으로 서버 버그입니다.

이 오류 코드는 로컬 응답 구문 분석(비정렬화) 오류와 쉽게 진단할 수 없는 광범위한 기타 낮은 수준 I/O 오류에도 할당됩니다.

내부 인터넷 서버 오류. 일반적으로 서버 버그입니다.
없는 서비스를 이용할 수 없습니다. 일반적으로 서버가 일시적으로 다운됩니다.

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

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

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

대부분의 API에서는 위 오류 코드의 하위 집합만 발생할 수 있습니다. 어떤 경우에도 오류 처리기를 구현할 때 이러한 모든 오류 코드를 명시적으로 처리할 것으로 예상되지는 않습니다. 대부분의 응용 프로그램은 1~2개의 특정 오류 코드에만 관심이 있으며 다른 모든 것은 복구할 수 없는 일반적인 오류로 처리합니다.

서비스별 오류 코드

Firebase 인증

CERTIFICATE_FETCH_FAILED JWT(ID 토큰 또는 세션 쿠키)를 확인하는 데 필요한 공개 키 인증서를 가져오지 못했습니다.
이메일이 이미 존재합니다 제공된 이메일을 사용하는 사용자가 이미 존재합니다.
EXPIRED_ID_TOKEN verifyIdToken() 에 지정된 ID 토큰이 만료되었습니다.
EXPIRED_SESSION_COOKIE verifySessionCookie() i에 지정된 세션 쿠키가 만료되었습니다.
INVALID_DYNAMIC_LINK_DOMAIN 제공된 동적 링크 도메인이 현재 프로젝트에 대해 구성되거나 승인되지 않았습니다. 이메일 작업 링크 API와 관련됩니다.
INVALID_ID_TOKEN verifyIdToken() 에 지정된 ID 토큰이 잘못되었습니다.
INVALID_SESSION_COOKIE verifySessionCookie() 에 지정된 세션 쿠키가 유효하지 않습니다.
PHONE_NUMBER_ALREADY_EXISTS 제공된 전화번호를 가진 사용자가 이미 존재합니다.
REVOKED_ID_TOKEN verifyIdToken() 에 지정된 ID 토큰이 취소됩니다.
REVOKED_SESSION_COOKIE verifySessionCookie() 에 지정된 세션 쿠키가 만료되었습니다.
UNAUTHORIZED_CONTINUE_URL 연결 URL의 도메인이 허용 목록에 없습니다. 이메일 작업 링크 API와 관련됩니다.
USER_NOT_FOUND 해당 식별자에 대한 사용자 기록을 찾을 수 없습니다.

Firebase 클라우드 메시징

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

자동 재시도

Admin SDK는 특정 오류를 사용자에게 노출하기 전에 자동으로 재시도합니다. 일반적으로 다음 유형의 오류는 투명하게 재시도됩니다.

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

SDK는 지수 백오프를 사용하여 위의 각 오류를 최대 5회(원래 시도 + 4회 재시도)까지 재시도합니다. 원하는 경우 애플리케이션 수준에서 고유한 재시도 메커니즘을 구현할 수 있지만 일반적으로 필수는 아닙니다.

재시도 후 지원

Admin SDK의 Go 및 .NET 구현은 HTTP Retry-After 헤더 처리를 지원합니다. 즉, 백엔드 서버에서 보낸 오류 응답에 표준 Retry-After 헤더가 포함되어 있는 경우 SDK는 지정된 대기 시간이 그리 길지 않은 한 재시도 시 이를 존중합니다. 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());
}

파이썬

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}')

가다

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

performPrivilegedOperation(token)

.그물

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());
  }
}

파이썬

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}')

가다

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)

.그물

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());
  }
}

파이썬

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}')

가다

_, 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
}

.그물

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());
  }
}

파이썬

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}')

가다

_, 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
}

.그물

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}");
  }
}