From 5565b1513c1aa0f2fce4468c9d916ea3868e9d98 Mon Sep 17 00:00:00 2001 From: putcho01 Date: Sat, 22 Nov 2025 19:36:04 +0900 Subject: [PATCH] feat: expose Firebase error information to SDK users --- errorutils/errorutils.go | 227 +++++++++++++++++++++++++++++ errorutils/errorutils_test.go | 264 ++++++++++++++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 errorutils/errorutils_test.go diff --git a/errorutils/errorutils.go b/errorutils/errorutils.go index fe81b756..f76602d0 100644 --- a/errorutils/errorutils.go +++ b/errorutils/errorutils.go @@ -21,6 +21,233 @@ import ( "firebase.google.com/go/v4/internal" ) +// ErrorCode represents the platform-wide error codes that can be raised by Firebase Admin SDK APIs. +type ErrorCode string + +const ( + // InvalidArgument indicates the request was invalid or malformed. + InvalidArgument ErrorCode = "INVALID_ARGUMENT" + + // FailedPrecondition indicates the request could not be executed in the current system state. + FailedPrecondition ErrorCode = "FAILED_PRECONDITION" + + // OutOfRange indicates an invalid range was specified by the client. + OutOfRange ErrorCode = "OUT_OF_RANGE" + + // Unauthenticated indicates the request lacks valid authentication credentials. + Unauthenticated ErrorCode = "UNAUTHENTICATED" + + // PermissionDenied indicates the client does not have sufficient permission. + PermissionDenied ErrorCode = "PERMISSION_DENIED" + + // NotFound indicates the specified resource was not found. + NotFound ErrorCode = "NOT_FOUND" + + // Conflict indicates a conflict occurred (e.g., concurrent modification). + Conflict ErrorCode = "CONFLICT" + + // Aborted indicates the operation was aborted (e.g., transaction conflict). + Aborted ErrorCode = "ABORTED" + + // AlreadyExists indicates the resource already exists. + AlreadyExists ErrorCode = "ALREADY_EXISTS" + + // ResourceExhausted indicates a quota or rate limit was exceeded. + ResourceExhausted ErrorCode = "RESOURCE_EXHAUSTED" + + // Cancelled indicates the operation was cancelled by the client. + Cancelled ErrorCode = "CANCELLED" + + // DataLoss indicates unrecoverable data loss or corruption. + DataLoss ErrorCode = "DATA_LOSS" + + // Unknown indicates an unknown server error. + Unknown ErrorCode = "UNKNOWN" + + // Internal indicates an internal server error. + Internal ErrorCode = "INTERNAL" + + // Unavailable indicates the service is currently unavailable. + Unavailable ErrorCode = "UNAVAILABLE" + + // DeadlineExceeded indicates the request exceeded the deadline. + DeadlineExceeded ErrorCode = "DEADLINE_EXCEEDED" +) + +// MessagingErrorCode represents FCM-specific error codes. +type MessagingErrorCode string + +const ( + // MessagingAPNSAuthError indicates an error with APNS authentication. + MessagingAPNSAuthError MessagingErrorCode = "APNS_AUTH_ERROR" + + // MessagingInternal indicates an internal messaging service error. + MessagingInternal MessagingErrorCode = "INTERNAL" + + // MessagingThirdPartyAuthError indicates an error with third-party authentication. + MessagingThirdPartyAuthError MessagingErrorCode = "THIRD_PARTY_AUTH_ERROR" + + // MessagingInvalidArgument indicates an invalid messaging argument. + MessagingInvalidArgument MessagingErrorCode = "INVALID_ARGUMENT" + + // MessagingQuotaExceeded indicates the messaging quota was exceeded. + MessagingQuotaExceeded MessagingErrorCode = "QUOTA_EXCEEDED" + + // MessagingSenderIDMismatch indicates a sender ID mismatch. + MessagingSenderIDMismatch MessagingErrorCode = "SENDER_ID_MISMATCH" + + // MessagingUnregistered indicates the device token is no longer valid. + MessagingUnregistered MessagingErrorCode = "UNREGISTERED" + + // MessagingUnavailable indicates the messaging service is unavailable. + MessagingUnavailable MessagingErrorCode = "UNAVAILABLE" +) + +// AuthErrorCode represents Auth-specific error codes. +type AuthErrorCode string + +const ( + // AuthConfigurationNotFound indicates the configuration was not found. + AuthConfigurationNotFound AuthErrorCode = "CONFIGURATION_NOT_FOUND" + + // AuthEmailAlreadyExists indicates the email address is already in use. + AuthEmailAlreadyExists AuthErrorCode = "EMAIL_ALREADY_EXISTS" + + // AuthEmailNotFound indicates no user record found for the email. + AuthEmailNotFound AuthErrorCode = "EMAIL_NOT_FOUND" + + // AuthInvalidDynamicLinkDomain indicates an invalid dynamic link domain. + AuthInvalidDynamicLinkDomain AuthErrorCode = "INVALID_DYNAMIC_LINK_DOMAIN" + + // AuthInvalidEmail indicates the email address is invalid. + AuthInvalidEmail AuthErrorCode = "INVALID_EMAIL" + + // AuthInvalidPageToken indicates the page token is invalid. + AuthInvalidPageToken AuthErrorCode = "INVALID_PAGE_TOKEN" + + // AuthPhoneNumberAlreadyExists indicates the phone number is already in use. + AuthPhoneNumberAlreadyExists AuthErrorCode = "PHONE_NUMBER_ALREADY_EXISTS" + + // AuthProjectNotFound indicates the project was not found. + AuthProjectNotFound AuthErrorCode = "PROJECT_NOT_FOUND" + + // AuthUIDAlreadyExists indicates the UID is already in use. + AuthUIDAlreadyExists AuthErrorCode = "UID_ALREADY_EXISTS" + + // AuthUnauthorizedContinueURL indicates an unauthorized continue URL. + AuthUnauthorizedContinueURL AuthErrorCode = "UNAUTHORIZED_CONTINUE_URL" + + // AuthUserNotFound indicates no user record found for the identifier. + AuthUserNotFound AuthErrorCode = "USER_NOT_FOUND" + + // AuthIDTokenExpired indicates the ID token has expired. + AuthIDTokenExpired AuthErrorCode = "ID_TOKEN_EXPIRED" + + // AuthIDTokenInvalid indicates the ID token is invalid. + AuthIDTokenInvalid AuthErrorCode = "ID_TOKEN_INVALID" + + // AuthIDTokenRevoked indicates the ID token has been revoked. + AuthIDTokenRevoked AuthErrorCode = "ID_TOKEN_REVOKED" + + // AuthSessionCookieExpired indicates the session cookie has expired. + AuthSessionCookieExpired AuthErrorCode = "SESSION_COOKIE_EXPIRED" + + // AuthSessionCookieInvalid indicates the session cookie is invalid. + AuthSessionCookieInvalid AuthErrorCode = "SESSION_COOKIE_INVALID" + + // AuthSessionCookieRevoked indicates the session cookie has been revoked. + AuthSessionCookieRevoked AuthErrorCode = "SESSION_COOKIE_REVOKED" + + // AuthUserDisabled indicates the user account has been disabled. + AuthUserDisabled AuthErrorCode = "USER_DISABLED" + + // AuthTenantIDMismatch indicates a tenant ID mismatch. + AuthTenantIDMismatch AuthErrorCode = "TENANT_ID_MISMATCH" + + // AuthCertificateFetchFailed indicates a failure to fetch certificates. + AuthCertificateFetchFailed AuthErrorCode = "CERTIFICATE_FETCH_FAILED" + + // AuthInsufficientPermission indicates insufficient permission. + AuthInsufficientPermission AuthErrorCode = "INSUFFICIENT_PERMISSION" + + // AuthTenantNotFound indicates the tenant was not found. + AuthTenantNotFound AuthErrorCode = "TENANT_NOT_FOUND" +) + +// FirebaseError represents an error returned by a Firebase service. +// It provides detailed information about platform-wide errors and service-specific error codes. +type FirebaseError struct { + // ErrorCode is the platform-wide error code (e.g., INVALID_ARGUMENT, NOT_FOUND). + ErrorCode ErrorCode + + // Message is the human-readable error message. + Message string + + // Response is the HTTP response that caused the error (buffered copy). + // This may be nil for errors not caused by HTTP responses. + Response *http.Response + + // MessagingErrorCode is the FCM-specific error code, if applicable. + // Empty string if this is not a messaging error. + MessagingErrorCode MessagingErrorCode + + // AuthErrorCode is the Auth-specific error code, if applicable. + // Empty string if this is not an auth error. + AuthErrorCode AuthErrorCode +} + +// Error implements the error interface. +func (e *FirebaseError) Error() string { + return e.Message +} + +// AsFirebaseError converts an error to a *FirebaseError if it is a Firebase error. +// Returns nil if the error is not a Firebase error. +// +// This function allows SDK users to access detailed error information including +// platform error codes, service-specific error codes, and HTTP response details. +// +// Example usage: +// +// _, err := client.Send(ctx, message) +// if fbErr := errorutils.AsFirebaseError(err); fbErr != nil { +// switch fbErr.ErrorCode { +// case errorutils.NotFound: +// // Handle not found error +// case errorutils.InvalidArgument: +// // Handle invalid argument error +// } +// +// // Access messaging-specific error codes +// if fbErr.MessagingErrorCode == errorutils.MessagingUnregistered { +// // Remove device token from database +// } +// } +func AsFirebaseError(err error) *FirebaseError { + fe, ok := err.(*internal.FirebaseError) + if !ok { + return nil + } + + pubErr := &FirebaseError{ + ErrorCode: ErrorCode(fe.ErrorCode), + Message: fe.String, + Response: fe.Response, + } + + // Extract messaging-specific error code if present + if msgCode, ok := fe.Ext["messagingErrorCode"].(string); ok { + pubErr.MessagingErrorCode = MessagingErrorCode(msgCode) + } + + // Extract auth-specific error code if present + if authCode, ok := fe.Ext["authErrorCode"].(string); ok { + pubErr.AuthErrorCode = AuthErrorCode(authCode) + } + + return pubErr +} + // IsInvalidArgument checks if the given error was due to an invalid client argument. func IsInvalidArgument(err error) bool { return internal.HasPlatformErrorCode(err, internal.InvalidArgument) diff --git a/errorutils/errorutils_test.go b/errorutils/errorutils_test.go new file mode 100644 index 00000000..a07cfcbf --- /dev/null +++ b/errorutils/errorutils_test.go @@ -0,0 +1,264 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package errorutils + +import ( + "net/http" + "testing" + + "firebase.google.com/go/v4/internal" +) + +func TestAsFirebaseError_Success(t *testing.T) { + internalErr := &internal.FirebaseError{ + ErrorCode: internal.InvalidArgument, + String: "test error message", + Response: &http.Response{ + StatusCode: 400, + }, + Ext: map[string]interface{}{ + "messagingErrorCode": "UNREGISTERED", + }, + } + + fbErr := AsFirebaseError(internalErr) + if fbErr == nil { + t.Fatal("AsFirebaseError() returned nil for valid FirebaseError") + } + + if fbErr.ErrorCode != InvalidArgument { + t.Errorf("ErrorCode = %v, want %v", fbErr.ErrorCode, InvalidArgument) + } + + if fbErr.Message != "test error message" { + t.Errorf("Message = %q, want %q", fbErr.Message, "test error message") + } + + if fbErr.Response == nil || fbErr.Response.StatusCode != 400 { + t.Error("Response not properly set") + } + + if fbErr.MessagingErrorCode != MessagingUnregistered { + t.Errorf("MessagingErrorCode = %v, want %v", fbErr.MessagingErrorCode, MessagingUnregistered) + } + + if fbErr.AuthErrorCode != "" { + t.Errorf("AuthErrorCode = %v, want empty string", fbErr.AuthErrorCode) + } +} + +func TestAsFirebaseError_AuthError(t *testing.T) { + internalErr := &internal.FirebaseError{ + ErrorCode: internal.NotFound, + String: "user not found", + Ext: map[string]interface{}{ + "authErrorCode": "USER_NOT_FOUND", + }, + } + + fbErr := AsFirebaseError(internalErr) + if fbErr == nil { + t.Fatal("AsFirebaseError() returned nil for valid FirebaseError") + } + + if fbErr.ErrorCode != NotFound { + t.Errorf("ErrorCode = %v, want %v", fbErr.ErrorCode, NotFound) + } + + if fbErr.AuthErrorCode != AuthUserNotFound { + t.Errorf("AuthErrorCode = %v, want %v", fbErr.AuthErrorCode, AuthUserNotFound) + } + + if fbErr.MessagingErrorCode != "" { + t.Errorf("MessagingErrorCode = %v, want empty string", fbErr.MessagingErrorCode) + } +} + +func TestAsFirebaseError_BothErrorCodes(t *testing.T) { + // This shouldn't happen in practice, but test it anyway + internalErr := &internal.FirebaseError{ + ErrorCode: internal.Internal, + String: "internal error", + Ext: map[string]interface{}{ + "messagingErrorCode": "INTERNAL", + "authErrorCode": "INSUFFICIENT_PERMISSION", + }, + } + + fbErr := AsFirebaseError(internalErr) + if fbErr == nil { + t.Fatal("AsFirebaseError() returned nil for valid FirebaseError") + } + + if fbErr.MessagingErrorCode != MessagingInternal { + t.Errorf("MessagingErrorCode = %v, want %v", fbErr.MessagingErrorCode, MessagingInternal) + } + + if fbErr.AuthErrorCode != AuthInsufficientPermission { + t.Errorf("AuthErrorCode = %v, want %v", fbErr.AuthErrorCode, AuthInsufficientPermission) + } +} + +func TestAsFirebaseError_NoServiceErrorCode(t *testing.T) { + internalErr := &internal.FirebaseError{ + ErrorCode: internal.Unavailable, + String: "service unavailable", + Ext: map[string]interface{}{}, + } + + fbErr := AsFirebaseError(internalErr) + if fbErr == nil { + t.Fatal("AsFirebaseError() returned nil for valid FirebaseError") + } + + if fbErr.ErrorCode != Unavailable { + t.Errorf("ErrorCode = %v, want %v", fbErr.ErrorCode, Unavailable) + } + + if fbErr.MessagingErrorCode != "" { + t.Errorf("MessagingErrorCode = %v, want empty string", fbErr.MessagingErrorCode) + } + + if fbErr.AuthErrorCode != "" { + t.Errorf("AuthErrorCode = %v, want empty string", fbErr.AuthErrorCode) + } +} + +func TestAsFirebaseError_NonFirebaseError(t *testing.T) { + // Test with a regular error + regularErr := &testError{msg: "regular error"} + + fbErr := AsFirebaseError(regularErr) + if fbErr != nil { + t.Errorf("AsFirebaseError() = %v, want nil for non-Firebase error", fbErr) + } + + // Test with nil + fbErr = AsFirebaseError(nil) + if fbErr != nil { + t.Errorf("AsFirebaseError(nil) = %v, want nil", fbErr) + } +} + +func TestFirebaseError_Error(t *testing.T) { + fbErr := &FirebaseError{ + ErrorCode: InvalidArgument, + Message: "test error message", + } + + if fbErr.Error() != "test error message" { + t.Errorf("Error() = %q, want %q", fbErr.Error(), "test error message") + } +} + +func TestErrorCodeConstants(t *testing.T) { + // Verify that platform error codes match internal error codes + tests := []struct { + public ErrorCode + internal internal.ErrorCode + }{ + {InvalidArgument, internal.InvalidArgument}, + {FailedPrecondition, internal.FailedPrecondition}, + {OutOfRange, internal.OutOfRange}, + {Unauthenticated, internal.Unauthenticated}, + {PermissionDenied, internal.PermissionDenied}, + {NotFound, internal.NotFound}, + {Conflict, internal.Conflict}, + {Aborted, internal.Aborted}, + {AlreadyExists, internal.AlreadyExists}, + {ResourceExhausted, internal.ResourceExhausted}, + {Cancelled, internal.Cancelled}, + {DataLoss, internal.DataLoss}, + {Unknown, internal.Unknown}, + {Internal, internal.Internal}, + {Unavailable, internal.Unavailable}, + {DeadlineExceeded, internal.DeadlineExceeded}, + } + + for _, tt := range tests { + if string(tt.public) != string(tt.internal) { + t.Errorf("ErrorCode mismatch: public=%q, internal=%q", tt.public, tt.internal) + } + } +} + +func TestMessagingErrorCodeValues(t *testing.T) { + // Verify messaging error code values are as expected + tests := []struct { + code MessagingErrorCode + expected string + }{ + {MessagingAPNSAuthError, "APNS_AUTH_ERROR"}, + {MessagingInternal, "INTERNAL"}, + {MessagingThirdPartyAuthError, "THIRD_PARTY_AUTH_ERROR"}, + {MessagingInvalidArgument, "INVALID_ARGUMENT"}, + {MessagingQuotaExceeded, "QUOTA_EXCEEDED"}, + {MessagingSenderIDMismatch, "SENDER_ID_MISMATCH"}, + {MessagingUnregistered, "UNREGISTERED"}, + {MessagingUnavailable, "UNAVAILABLE"}, + } + + for _, tt := range tests { + if string(tt.code) != tt.expected { + t.Errorf("MessagingErrorCode value mismatch: got %q, want %q", tt.code, tt.expected) + } + } +} + +func TestAuthErrorCodeValues(t *testing.T) { + // Verify auth error code values are as expected + tests := []struct { + code AuthErrorCode + expected string + }{ + {AuthConfigurationNotFound, "CONFIGURATION_NOT_FOUND"}, + {AuthEmailAlreadyExists, "EMAIL_ALREADY_EXISTS"}, + {AuthEmailNotFound, "EMAIL_NOT_FOUND"}, + {AuthInvalidDynamicLinkDomain, "INVALID_DYNAMIC_LINK_DOMAIN"}, + {AuthInvalidEmail, "INVALID_EMAIL"}, + {AuthInvalidPageToken, "INVALID_PAGE_TOKEN"}, + {AuthPhoneNumberAlreadyExists, "PHONE_NUMBER_ALREADY_EXISTS"}, + {AuthProjectNotFound, "PROJECT_NOT_FOUND"}, + {AuthUIDAlreadyExists, "UID_ALREADY_EXISTS"}, + {AuthUnauthorizedContinueURL, "UNAUTHORIZED_CONTINUE_URL"}, + {AuthUserNotFound, "USER_NOT_FOUND"}, + {AuthIDTokenExpired, "ID_TOKEN_EXPIRED"}, + {AuthIDTokenInvalid, "ID_TOKEN_INVALID"}, + {AuthIDTokenRevoked, "ID_TOKEN_REVOKED"}, + {AuthSessionCookieExpired, "SESSION_COOKIE_EXPIRED"}, + {AuthSessionCookieInvalid, "SESSION_COOKIE_INVALID"}, + {AuthSessionCookieRevoked, "SESSION_COOKIE_REVOKED"}, + {AuthUserDisabled, "USER_DISABLED"}, + {AuthTenantIDMismatch, "TENANT_ID_MISMATCH"}, + {AuthCertificateFetchFailed, "CERTIFICATE_FETCH_FAILED"}, + {AuthInsufficientPermission, "INSUFFICIENT_PERMISSION"}, + {AuthTenantNotFound, "TENANT_NOT_FOUND"}, + } + + for _, tt := range tests { + if string(tt.code) != tt.expected { + t.Errorf("AuthErrorCode value mismatch: got %q, want %q", tt.code, tt.expected) + } + } +} + +// testError is a simple error type for testing +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +}