Repository: firebase/firebase-admin-go
Pull request #370 (2020-05-06 22:49:06)

chore: Merging v4 branch into dev
Contains modules support and other breaking API changes.

API CHANGE: Admin SDK has opted into Go modules. The package name now has the major version appended to it. Developers should use the new package name `firebase.google.com/go/v4` when installing and importing the SDK.
RELEASE NOTE: change(fcm): The `messaging.WebpushFcmOptions` type has been renamed to `messaging.WebpushFCMOptions`.
RELEASE NOTE: feat(fcm): Added new service-level error checking functions `IsThirdPartyAuthError()`, `IsQuotaExceeded()`, `IsSenderIDMismatch()`, `IsUnregistered()` and `IsUnavailable()`.
RELEASE NOTE: feat: Added a new `errorutils` package that provides functions for checking for platform-level error codes.
RELEASE NOTE: feat(auth): Added new service-level error checking functions `IsIDTokenInvalid()`, `IsIDTokenExpired()`, `IsSessionCookieInvalid()`, `IsSessionCookieExpired()` and `IsCertificateFetchFailed()`. These functions can be used in conjunction with the existing JWT verification APIs provided by the SDK.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6153e81..eca8b98 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,38 +5,30 @@ jobs:
   build:
     name: Build
     runs-on: ubuntu-latest
-    env:
-        GOPATH: ${{ github.workspace }}/go
     strategy:
       matrix:
         go: [1.12, 1.13, 1.14]
-    steps:
 
+    steps:
     - name: Set up Go ${{ matrix.go }}
       uses: actions/setup-go@v1
       with:
         go-version: ${{ matrix.go }}
-      id: go
 
-    - name: Check out code into GOPATH
+    - name: Check out code
       uses: actions/checkout@v2
-      with:
-        path: go/src/firebase.google.com/go
-
-    - name: Get dependencies
-      run: go get -t -v $(go list ./... | grep -v integration)
 
     - name: Run Linter
       run: |
-        go get golang.org/x/lint/golint
-        $GOPATH/bin/golint -set_exit_status firebase.google.com/go/...
+        go get -u golang.org/x/lint/golint
+        GOLINT=`go list -f {{.Target}} golang.org/x/lint/golint`
+        $GOLINT -set_exit_status ./...
 
     - name: Run Unit Tests
       if: success() || failure()
-      run: go test -v -race -test.short firebase.google.com/go/...
+      run: go test -v -race -test.short ./...
 
     - name: Run Formatter
-      working-directory: ./go/src/firebase.google.com/go
       run: |
         if [[ ! -z "$(gofmt -l -s .)" ]]; then
           echo "Go code is not formatted:"
@@ -45,4 +37,4 @@ jobs:
         fi
 
     - name: Run Static Analyzer
-      run: go vet -v firebase.google.com/go/...
+      run: go vet -v ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3bfccd4..bdd5565 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,34 +36,26 @@ jobs:
 
     runs-on: ubuntu-latest
 
-    env:
-      GOPATH: ${{ github.workspace }}/go
-
     # When manually triggering the build, the requester can specify a target branch or a tag
     # via the 'ref' client parameter.
     steps:
-    - name: Check out code into GOPATH
-      uses: actions/checkout@v2
-      with:
-        path: go/src/firebase.google.com/go
-        ref: ${{ github.event.client_payload.ref || github.ref }}
-
     - name: Set up Go
       uses: actions/setup-go@v1
       with:
-        go-version: 1.11
+        go-version: 1.12
 
-    - name: Get dependencies
-      run: go get -t -v $(go list ./... | grep -v integration)
+    - name: Check out code
+      uses: actions/checkout@v2
+      with:
+        ref: ${{ github.event.client_payload.ref || github.ref }}
 
     - name: Run Linter
       run: |
-        echo
-        go get golang.org/x/lint/golint
-        $GOPATH/bin/golint -set_exit_status firebase.google.com/go/...
+        go get -u golang.org/x/lint/golint
+        GOLINT=`go list -f {{.Target}} golang.org/x/lint/golint`
+        $GOLINT -set_exit_status ./...
 
     - name: Run Tests
-      working-directory: ./go/src/firebase.google.com/go
       run: ./.github/scripts/run_all_tests.sh
       env:
         FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }}
diff --git a/auth/auth.go b/auth/auth.go
index 8a86e58..35600b8 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -23,13 +23,19 @@ import (
 	"strings"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/transport"
 )
 
 const (
+	authErrorCode    = "authErrorCode"
 	firebaseAudience = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"
 	oneHourInSeconds = 3600
+
+	// SDK-generated error codes
+	idTokenRevoked       = "ID_TOKEN_REVOKED"
+	sessionCookieRevoked = "SESSION_COOKIE_REVOKED"
+	tenantIDMismatch     = "TENANT_ID_MISMATCH"
 )
 
 var reservedClaims = []string{
@@ -102,7 +108,6 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error)
 
 	hc := internal.WithDefaultRetryConfig(transport)
 	hc.CreateErrFn = handleHTTPError
-	hc.SuccessFn = internal.HasSuccessStatus
 	hc.Opts = []internal.HTTPOption{
 		internal.WithHeader("X-Client-Version", fmt.Sprintf("Go/Admin/%s", conf.Version)),
 	}
@@ -261,12 +266,23 @@ func (c *baseClient) withTenantID(tenantID string) *baseClient {
 func (c *baseClient) VerifyIDToken(ctx context.Context, idToken string) (*Token, error) {
 	decoded, err := c.idTokenVerifier.VerifyToken(ctx, idToken)
 	if err == nil && c.tenantID != "" && c.tenantID != decoded.Firebase.Tenant {
-		return nil, internal.Errorf(tenantIDMismatch, "invalid tenant id: %q", decoded.Firebase.Tenant)
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    fmt.Sprintf("invalid tenant id: %q", decoded.Firebase.Tenant),
+			Ext: map[string]interface{}{
+				authErrorCode: tenantIDMismatch,
+			},
+		}
 	}
 
 	return decoded, err
 }
 
+// IsTenantIDMismatch checks if the given error was due to a mismatched tenant ID in a JWT.
+func IsTenantIDMismatch(err error) bool {
+	return hasAuthErrorCode(err, tenantIDMismatch)
+}
+
 // VerifyIDTokenAndCheckRevoked verifies the provided ID token, and additionally checks that the
 // token has not been revoked.
 //
@@ -284,12 +300,27 @@ func (c *baseClient) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken s
 	if err != nil {
 		return nil, err
 	}
+
 	if revoked {
-		return nil, internal.Error(idTokenRevoked, "ID token has been revoked")
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    "ID token has been revoked",
+			Ext: map[string]interface{}{
+				authErrorCode: idTokenRevoked,
+			},
+		}
 	}
+
 	return decoded, nil
 }
 
+// IsIDTokenRevoked checks if the given error was due to a revoked ID token.
+//
+// When IsIDTokenRevoked returns true, IsIDTokenInvalid is guranteed to return true.
+func IsIDTokenRevoked(err error) bool {
+	return hasAuthErrorCode(err, idTokenRevoked)
+}
+
 // VerifySessionCookie verifies the signature and payload of the provided Firebase session cookie.
 //
 // VerifySessionCookie accepts a signed JWT token string, and verifies that it is current, issued for the
@@ -324,12 +355,27 @@ func (c *Client) VerifySessionCookieAndCheckRevoked(ctx context.Context, session
 	if err != nil {
 		return nil, err
 	}
+
 	if revoked {
-		return nil, internal.Error(sessionCookieRevoked, "session cookie has been revoked")
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    "session cookie has been revoked",
+			Ext: map[string]interface{}{
+				authErrorCode: sessionCookieRevoked,
+			},
+		}
 	}
+
 	return decoded, nil
 }
 
+// IsSessionCookieRevoked checks if the given error was due to a revoked session cookie.
+//
+// When IsSessionCookieRevoked returns true, IsSessionCookieInvalid is guranteed to return true.
+func IsSessionCookieRevoked(err error) bool {
+	return hasAuthErrorCode(err, sessionCookieRevoked)
+}
+
 func (c *baseClient) checkRevoked(ctx context.Context, token *Token) (bool, error) {
 	user, err := c.GetUser(ctx, token.UID)
 	if err != nil {
@@ -338,3 +384,13 @@ func (c *baseClient) checkRevoked(ctx context.Context, token *Token) (bool, erro
 
 	return token.IssuedAt*1000 < user.TokensValidAfterMillis, nil
 }
+
+func hasAuthErrorCode(err error, code string) bool {
+	fe, ok := err.(*internal.FirebaseError)
+	if !ok {
+		return false
+	}
+
+	got, ok := fe.Ext[authErrorCode]
+	return ok && got == code
+}
diff --git a/auth/auth_appengine.go b/auth/auth_appengine.go
index 9b8ad6e..18a23f5 100644
--- a/auth/auth_appengine.go
+++ b/auth/auth_appengine.go
@@ -19,7 +19,7 @@ package auth
 import (
 	"context"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/appengine"
 )
 
diff --git a/auth/auth_std.go b/auth/auth_std.go
index 7c75717..46a3b4b 100644
--- a/auth/auth_std.go
+++ b/auth/auth_std.go
@@ -19,7 +19,7 @@ package auth // import "firebase.google.com/go/auth"
 import (
 	"context"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 func newCryptoSigner(ctx context.Context, conf *internal.AuthConfig) (cryptoSigner, error) {
diff --git a/auth/export_users.go b/auth/export_users.go
index baeb5bb..7e5670a 100644
--- a/auth/export_users.go
+++ b/auth/export_users.go
@@ -21,7 +21,7 @@ import (
 	"net/url"
 	"strconv"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/iterator"
 )
 
diff --git a/auth/hash/hash.go b/auth/hash/hash.go
index 114562a..67aa51a 100644
--- a/auth/hash/hash.go
+++ b/auth/hash/hash.go
@@ -22,7 +22,7 @@ import (
 	"errors"
 	"fmt"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 // Bcrypt represents the BCRYPT hash algorithm.
diff --git a/auth/import_users.go b/auth/import_users.go
index cfb9249..0febf31 100644
--- a/auth/import_users.go
+++ b/auth/import_users.go
@@ -20,7 +20,7 @@ import (
 	"errors"
 	"fmt"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 const maxImportUsers = 1000
diff --git a/auth/provider_config.go b/auth/provider_config.go
index ac87f4a..496e991 100644
--- a/auth/provider_config.go
+++ b/auth/provider_config.go
@@ -23,7 +23,7 @@ import (
 	"strconv"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/iterator"
 )
 
@@ -92,8 +92,8 @@ func (nm nestedMap) Set(key string, value interface{}) {
 	}
 }
 
-func (nm nestedMap) UpdateMask() ([]string, error) {
-	return buildMask(nm), nil
+func (nm nestedMap) UpdateMask() []string {
+	return buildMask(nm)
 }
 
 func buildMask(data map[string]interface{}) []string {
@@ -654,11 +654,7 @@ func (c *baseClient) UpdateOIDCProviderConfig(ctx context.Context, id string, co
 		return nil, err
 	}
 
-	mask, err := body.UpdateMask()
-	if err != nil {
-		return nil, fmt.Errorf("failed to construct update mask: %v", err)
-	}
-
+	mask := body.UpdateMask()
 	req := &internal.Request{
 		Method: http.MethodPatch,
 		URL:    fmt.Sprintf("/oauthIdpConfigs/%s", id),
@@ -766,11 +762,7 @@ func (c *baseClient) UpdateSAMLProviderConfig(ctx context.Context, id string, co
 		return nil, err
 	}
 
-	mask, err := body.UpdateMask()
-	if err != nil {
-		return nil, fmt.Errorf("failed to construct update mask: %v", err)
-	}
-
+	mask := body.UpdateMask()
 	req := &internal.Request{
 		Method: http.MethodPatch,
 		URL:    fmt.Sprintf("/inboundSamlConfigs/%s", id),
diff --git a/auth/tenant_mgt.go b/auth/tenant_mgt.go
index 3496b47..f35c6d7 100644
--- a/auth/tenant_mgt.go
+++ b/auth/tenant_mgt.go
@@ -22,7 +22,7 @@ import (
 	"strconv"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/iterator"
 )
 
@@ -153,11 +153,7 @@ func (tm *TenantManager) UpdateTenant(ctx context.Context, tenantID string, tena
 		return nil, errors.New("tenant must not be nil")
 	}
 
-	mask, err := tenant.params.UpdateMask()
-	if err != nil {
-		return nil, fmt.Errorf("failed to construct update mask: %v", err)
-	}
-
+	mask := tenant.params.UpdateMask()
 	if len(mask) == 0 {
 		return nil, errors.New("no parameters specified in the update request")
 	}
diff --git a/auth/token_generator.go b/auth/token_generator.go
index 98acad5..83e1202 100644
--- a/auth/token_generator.go
+++ b/auth/token_generator.go
@@ -30,8 +30,7 @@ import (
 	"strings"
 	"sync"
 
-	"firebase.google.com/go/internal"
-	"google.golang.org/api/transport"
+	"firebase.google.com/go/v4/internal"
 )
 
 type jwtHeader struct {
@@ -160,13 +159,14 @@ type iamSigner struct {
 }
 
 func newIAMSigner(ctx context.Context, config *internal.AuthConfig) (*iamSigner, error) {
-	hc, _, err := transport.NewHTTPClient(ctx, config.Opts...)
+	hc, _, err := internal.NewHTTPClient(ctx, config.Opts...)
 	if err != nil {
 		return nil, err
 	}
+
 	return &iamSigner{
 		mutex:        &sync.Mutex{},
-		httpClient:   &internal.HTTPClient{Client: hc},
+		httpClient:   hc,
 		serviceAcct:  config.ServiceAccountID,
 		metadataHost: "http://metadata.google.internal",
 		iamHost:      "https://iam.googleapis.com",
@@ -178,53 +178,31 @@ func (s iamSigner) Sign(ctx context.Context, b []byte) ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	url := fmt.Sprintf("%s/v1/projects/-/serviceAccounts/%s:signBlob", s.iamHost, account)
 	body := map[string]interface{}{
 		"bytesToSign": base64.StdEncoding.EncodeToString(b),
 	}
 	req := &internal.Request{
-		Method: "POST",
+		Method: http.MethodPost,
 		URL:    url,
 		Body:   internal.NewJSONEntity(body),
 	}
-	resp, err := s.httpClient.Do(ctx, req)
-	if err != nil {
-		return nil, err
-	} else if resp.Status == http.StatusOK {
-		var signResponse struct {
-			Signature string `json:"signature"`
-		}
-		if err := json.Unmarshal(resp.Body, &signResponse); err != nil {
-			return nil, err
-		}
-		return base64.StdEncoding.DecodeString(signResponse.Signature)
+	var signResponse struct {
+		Signature string `json:"signature"`
 	}
-	var signError struct {
-		Error struct {
-			Message string `json:"message"`
-			Status  string `json:"status"`
-		} `json:"error"`
-	}
-	json.Unmarshal(resp.Body, &signError) // ignore any json parse errors at this level
-	var (
-		clientCode, msg string
-		ok              bool
-	)
-	clientCode, ok = serverError[signError.Error.Status]
-	if !ok {
-		clientCode = unknown
-	}
-	msg = signError.Error.Message
-	if msg == "" {
-		msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body))
+	if _, err := s.httpClient.DoAndUnmarshal(ctx, req, &signResponse); err != nil {
+		return nil, err
 	}
-	return nil, internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg)
+
+	return base64.StdEncoding.DecodeString(signResponse.Signature)
 }
 
 func (s iamSigner) Email(ctx context.Context) (string, error) {
 	if s.serviceAcct != "" {
 		return s.serviceAcct, nil
 	}
+
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	result, err := s.callMetadataService(ctx)
@@ -235,29 +213,35 @@ func (s iamSigner) Email(ctx context.Context) (string, error) {
 			"for more details on creating custom tokens"
 		return "", fmt.Errorf(msg, err)
 	}
+
+	s.serviceAcct = result
 	return result, nil
 }
 
 func (s iamSigner) callMetadataService(ctx context.Context) (string, error) {
+	// Use the built-in default client without request authorization or retries for this call.
+	noAuthClient := &internal.HTTPClient{
+		Client: http.DefaultClient,
+	}
+
 	url := fmt.Sprintf("%s/computeMetadata/v1/instance/service-accounts/default/email", s.metadataHost)
 	req := &internal.Request{
-		Method: "GET",
+		Method: http.MethodGet,
 		URL:    url,
 		Opts: []internal.HTTPOption{
 			internal.WithHeader("Metadata-Flavor", "Google"),
 		},
 	}
-	resp, err := s.httpClient.Do(ctx, req)
+
+	resp, err := noAuthClient.Do(ctx, req)
 	if err != nil {
 		return "", err
 	}
-	if err := resp.CheckStatus(http.StatusOK); err != nil {
-		return "", err
-	}
+
 	result := strings.TrimSpace(string(resp.Body))
 	if result == "" {
 		return "", errors.New("unexpected response from metadata service")
 	}
-	s.serviceAcct = result
+
 	return result, nil
 }
diff --git a/auth/token_verifier.go b/auth/token_verifier.go
index 51b16cc..d91f64d 100644
--- a/auth/token_verifier.go
+++ b/auth/token_verifier.go
@@ -33,7 +33,7 @@ import (
 	"sync"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 	"google.golang.org/api/transport"
 )
@@ -44,8 +44,50 @@ const (
 	sessionCookieCertURL      = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys"
 	sessionCookieIssuerPrefix = "https://session.firebase.google.com/"
 	clockSkewSeconds          = 300
+	certificateFetchFailed    = "CERTIFICATE_FETCH_FAILED"
+	idTokenExpired            = "ID_TOKEN_EXPIRED"
+	idTokenInvalid            = "ID_TOKEN_INVALID"
+	sessionCookieExpired      = "SESSION_COOKIE_EXPIRED"
+	sessionCookieInvalid      = "SESSION_COOKIE_INVALID"
 )
 
+// IsCertificateFetchFailed checks if the given error was caused by a failure to fetch public key
+// certificates required to verify a JWT.
+func IsCertificateFetchFailed(err error) bool {
+	return hasAuthErrorCode(err, certificateFetchFailed)
+}
+
+// IsIDTokenExpired checks if the given error was due to an expired ID token.
+//
+// When IsIDTokenExpired returns true, IsIDTokenInvalid is guranteed to return true.
+func IsIDTokenExpired(err error) bool {
+	return hasAuthErrorCode(err, idTokenExpired)
+}
+
+// IsIDTokenInvalid checks if the given error was due to an invalid ID token.
+//
+// An ID token is considered invalid when it is malformed (i.e. contains incorrect data), expired
+// or revoked.
+func IsIDTokenInvalid(err error) bool {
+	return hasAuthErrorCode(err, idTokenInvalid) || IsIDTokenExpired(err) || IsIDTokenRevoked(err)
+}
+
+// IsSessionCookieExpired checks if the given error was due to an expired session cookie.
+//
+// When IsSessionCookieExpired returns true, IsSessionCookieInvalid is guranteed to return true.
+func IsSessionCookieExpired(err error) bool {
+	return hasAuthErrorCode(err, sessionCookieExpired)
+}
+
+// IsSessionCookieInvalid checks if the given error was due to an invalid session cookie.
+//
+// A session cookie is considered invalid when it is malformed (i.e. contains incorrect data),
+// expired or revoked.
+func IsSessionCookieInvalid(err error) bool {
+	return hasAuthErrorCode(err, sessionCookieInvalid) || IsSessionCookieExpired(err) ||
+		IsSessionCookieRevoked(err)
+}
+
 // tokenVerifier verifies different types of Firebase token strings, including ID tokens and
 // session cookies.
 type tokenVerifier struct {
@@ -54,6 +96,8 @@ type tokenVerifier struct {
 	docURL            string
 	projectID         string
 	issuerPrefix      string
+	invalidTokenCode  string
+	expiredTokenCode  string
 	keySource         keySource
 	clock             internal.Clock
 }
@@ -63,12 +107,15 @@ func newIDTokenVerifier(ctx context.Context, projectID string) (*tokenVerifier,
 	if err != nil {
 		return nil, err
 	}
+
 	return &tokenVerifier{
 		shortName:         "ID token",
 		articledShortName: "an ID token",
 		docURL:            "https://firebase.google.com/docs/auth/admin/verify-id-tokens",
 		projectID:         projectID,
 		issuerPrefix:      idTokenIssuerPrefix,
+		invalidTokenCode:  idTokenInvalid,
+		expiredTokenCode:  idTokenExpired,
 		keySource:         newHTTPKeySource(idTokenCertURL, noAuthHTTPClient),
 		clock:             internal.SystemClock,
 	}, nil
@@ -79,12 +126,15 @@ func newSessionCookieVerifier(ctx context.Context, projectID string) (*tokenVeri
 	if err != nil {
 		return nil, err
 	}
+
 	return &tokenVerifier{
 		shortName:         "session cookie",
 		articledShortName: "a session cookie",
 		docURL:            "https://firebase.google.com/docs/auth/admin/manage-cookies",
 		projectID:         projectID,
 		issuerPrefix:      sessionCookieIssuerPrefix,
+		invalidTokenCode:  sessionCookieInvalid,
+		expiredTokenCode:  sessionCookieExpired,
 		keySource:         newHTTPKeySource(sessionCookieCertURL, noAuthHTTPClient),
 		clock:             internal.SystemClock,
 	}, nil
@@ -105,17 +155,14 @@ func newSessionCookieVerifier(ctx context.Context, projectID string) (*tokenVeri
 // decoded Token is returned.
 func (tv *tokenVerifier) VerifyToken(ctx context.Context, token string) (*Token, error) {
 	if tv.projectID == "" {
+		// Configuration error.
 		return nil, errors.New("project id not available")
 	}
-	if token == "" {
-		return nil, fmt.Errorf("%s must be a non-empty string", tv.shortName)
-	}
 
 	// Validate the token content first. This is fast and cheap.
 	payload, err := tv.verifyContent(token)
 	if err != nil {
-		return nil, fmt.Errorf("%s; see %s for details on how to retrieve a valid %s",
-			err.Error(), tv.docURL, tv.shortName)
+		return nil, err
 	}
 
 	if err := tv.verifyTimestamps(payload); err != nil {
@@ -127,10 +174,75 @@ func (tv *tokenVerifier) VerifyToken(ctx context.Context, token string) (*Token,
 	if err := tv.verifySignature(ctx, token); err != nil {
 		return nil, err
 	}
+
 	return payload, nil
 }
 
 func (tv *tokenVerifier) verifyContent(token string) (*Token, error) {
+	if token == "" {
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    fmt.Sprintf("%s must be a non-empty string", tv.shortName),
+			Ext:       map[string]interface{}{authErrorCode: tv.invalidTokenCode},
+		}
+	}
+
+	payload, err := tv.verifyHeaderAndBody(token)
+	if err != nil {
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String: fmt.Sprintf(
+				"%s; see %s for details on how to retrieve a valid %s",
+				err.Error(), tv.docURL, tv.shortName),
+			Ext: map[string]interface{}{authErrorCode: tv.invalidTokenCode},
+		}
+	}
+
+	return payload, nil
+}
+
+func (tv *tokenVerifier) verifyTimestamps(payload *Token) error {
+	if (payload.IssuedAt - clockSkewSeconds) > tv.clock.Now().Unix() {
+		return &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    fmt.Sprintf("%s issued at future timestamp: %d", tv.shortName, payload.IssuedAt),
+			Ext:       map[string]interface{}{authErrorCode: tv.invalidTokenCode},
+		}
+	}
+
+	if (payload.Expires + clockSkewSeconds) < tv.clock.Now().Unix() {
+		return &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    fmt.Sprintf("%s has expired at: %d", tv.shortName, payload.Expires),
+			Ext:       map[string]interface{}{authErrorCode: tv.expiredTokenCode},
+		}
+	}
+
+	return nil
+}
+
+func (tv *tokenVerifier) verifySignature(ctx context.Context, token string) error {
+	keys, err := tv.keySource.Keys(ctx)
+	if err != nil {
+		return &internal.FirebaseError{
+			ErrorCode: internal.Unknown,
+			String:    err.Error(),
+			Ext:       map[string]interface{}{authErrorCode: certificateFetchFailed},
+		}
+	}
+
+	if !tv.verifySignatureWithKeys(ctx, token, keys) {
+		return &internal.FirebaseError{
+			ErrorCode: internal.InvalidArgument,
+			String:    "failed to verify token signature",
+			Ext:       map[string]interface{}{authErrorCode: tv.invalidTokenCode},
+		}
+	}
+
+	return nil
+}
+
+func (tv *tokenVerifier) verifyHeaderAndBody(token string) (*Token, error) {
 	var (
 		header  jwtHeader
 		payload Token
@@ -190,27 +302,10 @@ func (tv *tokenVerifier) verifyContent(token string) (*Token, error) {
 	return &payload, nil
 }
 
-func (tv *tokenVerifier) verifyTimestamps(payload *Token) error {
-	if (payload.IssuedAt - clockSkewSeconds) > tv.clock.Now().Unix() {
-		return fmt.Errorf("%s issued at future timestamp: %d", tv.shortName, payload.IssuedAt)
-	} else if (payload.Expires + clockSkewSeconds) < tv.clock.Now().Unix() {
-		return fmt.Errorf("%s has expired at: %d", tv.shortName, payload.Expires)
-	}
-	return nil
-}
-
-func (tv *tokenVerifier) verifySignature(ctx context.Context, token string) error {
+func (tv *tokenVerifier) verifySignatureWithKeys(ctx context.Context, token string, keys []*publicKey) bool {
 	segments := strings.Split(token, ".")
-
 	var h jwtHeader
-	if err := decode(segments[0], &h); err != nil {
-		return err
-	}
-
-	keys, err := tv.keySource.Keys(ctx)
-	if err != nil {
-		return err
-	}
+	decode(segments[0], &h)
 
 	verified := false
 	for _, k := range keys {
@@ -221,10 +316,8 @@ func (tv *tokenVerifier) verifySignature(ctx context.Context, token string) erro
 			}
 		}
 	}
-	if !verified {
-		return errors.New("failed to verify token signature")
-	}
-	return nil
+
+	return verified
 }
 
 func (tv *tokenVerifier) getProjectIDMatchMessage() string {
@@ -308,7 +401,7 @@ func (k *httpKeySource) hasExpired() bool {
 
 func (k *httpKeySource) refreshKeys(ctx context.Context) error {
 	k.CachedKeys = nil
-	req, err := http.NewRequest("GET", k.KeyURI, nil)
+	req, err := http.NewRequest(http.MethodGet, k.KeyURI, nil)
 	if err != nil {
 		return err
 	}
@@ -323,18 +416,22 @@ func (k *httpKeySource) refreshKeys(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
+
 	if resp.StatusCode != http.StatusOK {
 		return fmt.Errorf("invalid response (%d) while retrieving public keys: %s",
 			resp.StatusCode, string(contents))
 	}
+
 	newKeys, err := parsePublicKeys(contents)
 	if err != nil {
 		return err
 	}
+
 	maxAge, err := findMaxAge(resp)
 	if err != nil {
 		return err
 	}
+
 	k.CachedKeys = append([]*publicKey(nil), newKeys...)
 	k.ExpiryTime = k.Clock.Now().Add(*maxAge)
 	return nil
diff --git a/auth/user_mgt.go b/auth/user_mgt.go
index e601d8b..5c84ed8 100644
--- a/auth/user_mgt.go
+++ b/auth/user_mgt.go
@@ -26,8 +26,7 @@ import (
 	"strings"
 	"time"
 
-	"firebase.google.com/go/internal"
-	"google.golang.org/api/googleapi"
+	"firebase.google.com/go/v4/internal"
 )
 
 const (
@@ -325,126 +324,83 @@ func marshalCustomClaims(claims map[string]interface{}) (string, error) {
 // Error handlers.
 
 const (
-	configurationNotFound    = "configuration-not-found"
-	emailAlreadyExists       = "email-already-exists"
-	idTokenRevoked           = "id-token-revoked"
-	insufficientPermission   = "insufficient-permission"
-	invalidDynamicLinkDomain = "invalid-dynamic-link-domain"
-	invalidEmail             = "invalid-email"
-	phoneNumberAlreadyExists = "phone-number-already-exists"
-	projectNotFound          = "project-not-found"
-	sessionCookieRevoked     = "session-cookie-revoked"
-	tenantIDMismatch         = "tenant-id-mismatch"
-	tenantNotFound           = "tenant-not-found"
-	uidAlreadyExists         = "uid-already-exists"
-	unauthorizedContinueURI  = "unauthorized-continue-uri"
-	unknown                  = "unknown-error"
-	userNotFound             = "user-not-found"
+	// Backend-generated error codes
+	configurationNotFound    = "CONFIGURATION_NOT_FOUND"
+	emailAlreadyExists       = "EMAIL_ALREADY_EXISTS"
+	invalidDynamicLinkDomain = "INVALID_DYNAMIC_LINK_DOMAIN"
+	phoneNumberAlreadyExists = "PHONE_NUMBER_ALREADY_EXISTS"
+	tenantNotFound           = "TENANT_NOT_FOUND"
+	uidAlreadyExists         = "UID_ALREADY_EXISTS"
+	unauthorizedContinueURI  = "UNAUTHORIZED_CONTINUE_URI"
+	userNotFound             = "USER_NOT_FOUND"
 )
 
 // IsConfigurationNotFound checks if the given error was due to a non-existing IdP configuration.
 func IsConfigurationNotFound(err error) bool {
-	return internal.HasErrorCode(err, configurationNotFound)
+	return hasAuthErrorCode(err, configurationNotFound)
 }
 
 // IsEmailAlreadyExists checks if the given error was due to a duplicate email.
 func IsEmailAlreadyExists(err error) bool {
-	return internal.HasErrorCode(err, emailAlreadyExists)
-}
-
-// IsIDTokenRevoked checks if the given error was due to a revoked ID token.
-func IsIDTokenRevoked(err error) bool {
-	return internal.HasErrorCode(err, idTokenRevoked)
+	return hasAuthErrorCode(err, emailAlreadyExists)
 }
 
 // IsInsufficientPermission checks if the given error was due to insufficient permissions.
+//
+// Deprecated. Always returns false.
 func IsInsufficientPermission(err error) bool {
-	return internal.HasErrorCode(err, insufficientPermission)
+	return false
 }
 
 // IsInvalidDynamicLinkDomain checks if the given error was due to an invalid dynamic link domain.
 func IsInvalidDynamicLinkDomain(err error) bool {
-	return internal.HasErrorCode(err, invalidDynamicLinkDomain)
+	return hasAuthErrorCode(err, invalidDynamicLinkDomain)
 }
 
 // IsInvalidEmail checks if the given error was due to an invalid email.
+//
+// Deprecated. Always returns false.
 func IsInvalidEmail(err error) bool {
-	return internal.HasErrorCode(err, invalidEmail)
+	return false
 }
 
 // IsPhoneNumberAlreadyExists checks if the given error was due to a duplicate phone number.
 func IsPhoneNumberAlreadyExists(err error) bool {
-	return internal.HasErrorCode(err, phoneNumberAlreadyExists)
+	return hasAuthErrorCode(err, phoneNumberAlreadyExists)
 }
 
 // IsProjectNotFound checks if the given error was due to a non-existing project.
+//
+// Deprecated. Always returns false.
 func IsProjectNotFound(err error) bool {
-	return internal.HasErrorCode(err, projectNotFound)
-}
-
-// IsSessionCookieRevoked checks if the given error was due to a revoked session cookie.
-func IsSessionCookieRevoked(err error) bool {
-	return internal.HasErrorCode(err, sessionCookieRevoked)
-}
-
-// IsTenantIDMismatch checks if the given error was due to a mismatched tenant ID in a JWT.
-func IsTenantIDMismatch(err error) bool {
-	return internal.HasErrorCode(err, tenantIDMismatch)
+	return false
 }
 
 // IsTenantNotFound checks if the given error was due to a non-existing tenant ID.
 func IsTenantNotFound(err error) bool {
-	return internal.HasErrorCode(err, tenantNotFound)
+	return hasAuthErrorCode(err, tenantNotFound)
 }
 
 // IsUIDAlreadyExists checks if the given error was due to a duplicate uid.
 func IsUIDAlreadyExists(err error) bool {
-	return internal.HasErrorCode(err, uidAlreadyExists)
+	return hasAuthErrorCode(err, uidAlreadyExists)
 }
 
 // IsUnauthorizedContinueURI checks if the given error was due to an unauthorized continue URI domain.
 func IsUnauthorizedContinueURI(err error) bool {
-	return internal.HasErrorCode(err, unauthorizedContinueURI)
+	return hasAuthErrorCode(err, unauthorizedContinueURI)
 }
 
 // IsUnknown checks if the given error was due to a unknown server error.
+//
+// Deprecated. Always returns false.
 func IsUnknown(err error) bool {
-	return internal.HasErrorCode(err, unknown)
+	return false
 }
 
 // IsUserNotFound checks if the given error was due to non-existing user.
 func IsUserNotFound(err error) bool {
-	return internal.HasErrorCode(err, userNotFound)
-}
-
-var serverError = map[string]string{
-	"CONFIGURATION_NOT_FOUND":     configurationNotFound,
-	"DUPLICATE_EMAIL":             emailAlreadyExists,
-	"DUPLICATE_LOCAL_ID":          uidAlreadyExists,
-	"EMAIL_EXISTS":                emailAlreadyExists,
-	"INSUFFICIENT_PERMISSION":     insufficientPermission,
-	"INVALID_DYNAMIC_LINK_DOMAIN": invalidDynamicLinkDomain,
-	"INVALID_EMAIL":               invalidEmail,
-	"PERMISSION_DENIED":           insufficientPermission,
-	"PHONE_NUMBER_EXISTS":         phoneNumberAlreadyExists,
-	"PROJECT_NOT_FOUND":           projectNotFound,
-	"TENANT_NOT_FOUND":            tenantNotFound,
-	"UNAUTHORIZED_DOMAIN":         unauthorizedContinueURI,
-	"USER_NOT_FOUND":              userNotFound,
-}
-
-func handleServerError(err error) error {
-	gerr, ok := err.(*googleapi.Error)
-	if !ok {
-		// Not a back-end error
-		return err
-	}
-	serverCode := gerr.Message
-	clientCode, ok := serverError[serverCode]
-	if !ok {
-		clientCode = unknown
-	}
-	return internal.Error(clientCode, err.Error())
+	return hasAuthErrorCode(err, userNotFound)
 }
 
 // Validators.
@@ -569,12 +525,20 @@ type getAccountInfoResponse struct {
 
 func (c *baseClient) getUser(ctx context.Context, query *userQuery) (*UserRecord, error) {
 	var parsed getAccountInfoResponse
-	if _, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed); err != nil {
+	resp, err := c.post(ctx, "/accounts:lookup", query.build(), &parsed)
+	if err != nil {
 		return nil, err
 	}
 
 	if len(parsed.Users) == 0 {
-		return nil, internal.Errorf(userNotFound, "cannot find user from %s", query.description())
+		return nil, &internal.FirebaseError{
+			ErrorCode: internal.NotFound,
+			String:    fmt.Sprintf("no user exists with the %s", query.description()),
+			Response:  resp.LowLevelResponse(),
+			Ext: map[string]interface{}{
+				authErrorCode: userNotFound,
+			},
+		}
 	}
 
 	return parsed.Users[0].makeUserRecord()
@@ -1096,21 +1060,92 @@ func (c *baseClient) makeUserMgtURL(path string) (string, error) {
 	return url, nil
 }
 
+type authError struct {
+	code     internal.ErrorCode
+	message  string
+	authCode string
+}
+
+var serverError = map[string]*authError{
+	"CONFIGURATION_NOT_FOUND": {
+		code:     internal.NotFound,
+		message:  "no IdP configuration corresponding to the provided identifier",
+		authCode: configurationNotFound,
+	},
+	"DUPLICATE_EMAIL": {
+		code:     internal.AlreadyExists,
+		message:  "user with the provided email already exists",
+		authCode: emailAlreadyExists,
+	},
+	"DUPLICATE_LOCAL_ID": {
+		code:     internal.AlreadyExists,
+		message:  "user with the provided uid already exists",
+		authCode: uidAlreadyExists,
+	},
+	"EMAIL_EXISTS": {
+		code:     internal.AlreadyExists,
+		message:  "user with the provided email already exists",
+		authCode: emailAlreadyExists,
+	},
+	"INVALID_DYNAMIC_LINK_DOMAIN": {
+		code:     internal.InvalidArgument,
+		message:  "the provided dynamic link domain is not configured or authorized for the current project",
+		authCode: invalidDynamicLinkDomain,
+	},
+	"PHONE_NUMBER_EXISTS": {
+		code:     internal.AlreadyExists,
+		message:  "user with the provided phone number already exists",
+		authCode: phoneNumberAlreadyExists,
+	},
+	"TENANT_NOT_FOUND": {
+		code:     internal.NotFound,
+		message:  "tenant with the specified ID does not exist",
+		authCode: tenantNotFound,
+	},
+	"UNAUTHORIZED_DOMAIN": {
+		code:     internal.InvalidArgument,
+		message:  "domain of the continue url is not whitelisted",
+		authCode: unauthorizedContinueURI,
+	},
+	"USER_NOT_FOUND": {
+		code:     internal.NotFound,
+		message:  "no user record found for the given identifier",
+		authCode: userNotFound,
+	},
+}
+
 func handleHTTPError(resp *internal.Response) error {
+	err := internal.NewFirebaseError(resp)
+	code, detail := parseErrorResponse(resp)
+	if authErr, ok := serverError[code]; ok {
+		err.ErrorCode = authErr.code
+		err.Ext[authErrorCode] = authErr.authCode
+		if detail != "" {
+			err.String = fmt.Sprintf("%s: %s", authErr.message, detail)
+		} else {
+			err.String = authErr.message
+		}
+	}
+
+	return err
+}
+
+func parseErrorResponse(resp *internal.Response) (string, string) {
 	var httpErr struct {
 		Error struct {
 			Message string `json:"message"`
 		} `json:"error"`
 	}
-	json.Unmarshal(resp.Body, &httpErr) // ignore any json parse errors at this level
-	serverCode := httpErr.Error.Message
-	clientCode, ok := serverError[serverCode]
-	if !ok {
-		clientCode = unknown
-	}
-	return internal.Errorf(
-		clientCode,
-		"http error status: %d; body: %s",
-		resp.Status,
-		string(resp.Body))
+	// ignore any json parse errors at this level
+	json.Unmarshal(resp.Body, &httpErr)
+
+	// Auth error response format: {"error": {"message": "AUTH_ERROR_CODE: Optional text"}}
+	code, detail := httpErr.Error.Message, ""
+	idx := strings.Index(code, ":")
+	if idx != -1 {
+		detail = strings.TrimSpace(code[idx+1:])
+		code = code[:idx]
+	}
+
+	return code, detail
 }
diff --git a/db/db.go b/db/db.go
index 728509a..6d2774b 100644
--- a/db/db.go
+++ b/db/db.go
@@ -23,7 +23,7 @@ import (
 	"runtime"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 )
 
@@ -68,17 +68,7 @@ func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error)
 		return nil, err
 	}
 
-	ep := func(b []byte) string {
-		var p struct {
-			Error string `json:"error"`
-		}
-		if err := json.Unmarshal(b, &p); err != nil {
-			return ""
-		}
-		return p.Error
-	}
-	hc.ErrParser = ep
-
+	hc.CreateErrFn = handleRTDBError
 	return &Client{
 		hc:           hc,
 		url:          fmt.Sprintf("https://%s", p.Host),
@@ -102,24 +92,18 @@ func (c *Client) NewRef(path string) *Ref {
 	}
 }
 
-func (c *Client) send(
-	ctx context.Context,
-	method, path string,
-	body internal.HTTPEntity,
-	opts ...internal.HTTPOption) (*internal.Response, error) {
-
-	if strings.ContainsAny(path, invalidChars) {
-		return nil, fmt.Errorf("invalid path with illegal characters: %q", path)
+func (c *Client) sendAndUnmarshal(
+	ctx context.Context, req *internal.Request, v interface{}) (*internal.Response, error) {
+	if strings.ContainsAny(req.URL, invalidChars) {
+		return nil, fmt.Errorf("invalid path with illegal characters: %q", req.URL)
 	}
+
+	req.URL = fmt.Sprintf("%s%s.json", c.url, req.URL)
 	if c.authOverride != "" {
-		opts = append(opts, internal.WithQueryParam(authVarOverride, c.authOverride))
+		req.Opts = append(req.Opts, internal.WithQueryParam(authVarOverride, c.authOverride))
 	}
-	return c.hc.Do(ctx, &internal.Request{
-		Method: method,
-		URL:    fmt.Sprintf("%s%s.json", c.url, path),
-		Body:   body,
-		Opts:   opts,
-	})
+
+	return c.hc.DoAndUnmarshal(ctx, req, v)
 }
 
 func parsePath(path string) []string {
@@ -131,3 +115,16 @@ func parsePath(path string) []string {
 	}
 	return segs
 }
+
+func handleRTDBError(resp *internal.Response) error {
+	err := internal.NewFirebaseError(resp)
+	var p struct {
+		Error string `json:"error"`
+	}
+	json.Unmarshal(resp.Body, &p)
+	if p.Error != "" {
+		err.String = fmt.Sprintf("http error status: %d; reason: %s", resp.Status, p.Error)
+	}
+
+	return err
+}
diff --git a/db/query.go b/db/query.go
index ca88b13..ff6a394 100644
--- a/db/query.go
+++ b/db/query.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package db // import "firebase.google.com/go/db"
+package db // import "firebase.google.com/go/v4/db"
 
 import (
 	"context"
@@ -23,7 +23,7 @@ import (
 	"strconv"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 // QueryNode represents a data node retrieved from an ordered query.
@@ -109,11 +109,14 @@ func (q *Query) Get(ctx context.Context, v interface{}) error {
 	if err := initQueryParams(q, qp); err != nil {
 		return err
 	}
-	resp, err := q.client.send(ctx, "GET", q.path, nil, internal.WithQueryParams(qp))
-	if err != nil {
-		return err
+
+	req := &internal.Request{
+		Method: http.MethodGet,
+		URL:    q.path,
+		Opts:   []internal.HTTPOption{internal.WithQueryParams(qp)},
 	}
-	return resp.Unmarshal(http.StatusOK, v)
+	_, err := q.client.sendAndUnmarshal(ctx, req, v)
+	return err
 }
 
 // GetOrdered executes the Query and returns the results as an ordered slice.
diff --git a/db/ref.go b/db/ref.go
index 821ce85..bb26a53 100644
--- a/db/ref.go
+++ b/db/ref.go
@@ -21,7 +21,7 @@ import (
 	"net/http"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 // txnRetires is the maximum number of times a transaction is retried before giving up. Transaction
@@ -75,21 +75,26 @@ func (r *Ref) Child(path string) *Ref {
 // therefore v has the same requirements as the json package. Specifically, it must be a pointer,
 // and must not be nil.
 func (r *Ref) Get(ctx context.Context, v interface{}) error {
-	resp, err := r.send(ctx, "GET")
-	if err != nil {
-		return err
+	req := &internal.Request{
+		Method: http.MethodGet,
 	}
-	return resp.Unmarshal(http.StatusOK, v)
+	_, err := r.sendAndUnmarshal(ctx, req, v)
+	return err
 }
 
 // GetWithETag retrieves the value at the current database location, along with its ETag.
 func (r *Ref) GetWithETag(ctx context.Context, v interface{}) (string, error) {
-	resp, err := r.send(ctx, "GET", internal.WithHeader("X-Firebase-ETag", "true"))
+	req := &internal.Request{
+		Method: http.MethodGet,
+		Opts: []internal.HTTPOption{
+			internal.WithHeader("X-Firebase-ETag", "true"),
+		},
+	}
+	resp, err := r.sendAndUnmarshal(ctx, req, v)
 	if err != nil {
 		return "", err
-	} else if err := resp.Unmarshal(http.StatusOK, v); err != nil {
-		return "", err
 	}
+
 	return resp.Header.Get("Etag"), nil
 }
 
@@ -97,11 +102,14 @@ func (r *Ref) GetWithETag(ctx context.Context, v interface{}) (string, error) {
 //
 // Shallow reads do not retrieve the child nodes of the current reference.
 func (r *Ref) GetShallow(ctx context.Context, v interface{}) error {
-	resp, err := r.send(ctx, "GET", internal.WithQueryParam("shallow", "true"))
-	if err != nil {
-		return err
+	req := &internal.Request{
+		Method: http.MethodGet,
+		Opts: []internal.HTTPOption{
+			internal.WithQueryParam("shallow", "true"),
+		},
 	}
-	return resp.Unmarshal(http.StatusOK, v)
+	_, err := r.sendAndUnmarshal(ctx, req, v)
+	return err
 }
 
 // GetIfChanged retrieves the value and ETag of the current database location only if the specified
@@ -112,16 +120,26 @@ func (r *Ref) GetShallow(ctx context.Context, v interface{}) error {
 // If the etag matches, returns false along with the same ETag passed into the function. No data
 // will be stored in v in this case.
 func (r *Ref) GetIfChanged(ctx context.Context, etag string, v interface{}) (bool, string, error) {
-	resp, err := r.send(ctx, "GET", internal.WithHeader("If-None-Match", etag))
+	req := &internal.Request{
+		Method: http.MethodGet,
+		Opts: []internal.HTTPOption{
+			internal.WithHeader("If-None-Match", etag),
+		},
+		SuccessFn: successOrNotModified,
+	}
+	resp, err := r.sendAndUnmarshal(ctx, req, nil)
 	if err != nil {
 		return false, "", err
 	}
+
 	if resp.Status == http.StatusNotModified {
 		return false, etag, nil
 	}
-	if err := resp.Unmarshal(http.StatusOK, v); err != nil {
+
+	if err := json.Unmarshal(resp.Body, v); err != nil {
 		return false, "", err
 	}
+
 	return true, resp.Header.Get("ETag"), nil
 }
 
@@ -131,11 +149,15 @@ func (r *Ref) GetIfChanged(ctx context.Context, etag string, v interface{}) (boo
 // v has the same requirements as the json package. Values like functions and channels cannot be
 // saved into Realtime Database.
 func (r *Ref) Set(ctx context.Context, v interface{}) error {
-	resp, err := r.sendWithBody(ctx, "PUT", v, internal.WithQueryParam("print", "silent"))
-	if err != nil {
-		return err
+	req := &internal.Request{
+		Method: http.MethodPut,
+		Body:   internal.NewJSONEntity(v),
+		Opts: []internal.HTTPOption{
+			internal.WithQueryParam("print", "silent"),
+		},
 	}
-	return resp.CheckStatus(http.StatusNoContent)
+	_, err := r.sendAndUnmarshal(ctx, req, nil)
+	return err
 }
 
 // SetIfUnchanged conditionally sets the data at this location to the given value.
@@ -143,16 +165,23 @@ func (r *Ref) Set(ctx context.Context, v interface{}) error {
 // Sets the data at this location to v only if the specified ETag matches. Returns true if the
 // value is written. Returns false if no changes are made to the database.
 func (r *Ref) SetIfUnchanged(ctx context.Context, etag string, v interface{}) (bool, error) {
-	resp, err := r.sendWithBody(ctx, "PUT", v, internal.WithHeader("If-Match", etag))
+	req := &internal.Request{
+		Method: http.MethodPut,
+		Body:   internal.NewJSONEntity(v),
+		Opts: []internal.HTTPOption{
+			internal.WithHeader("If-Match", etag),
+		},
+		SuccessFn: successOrPreconditionFailed,
+	}
+	resp, err := r.sendAndUnmarshal(ctx, req, nil)
 	if err != nil {
 		return false, err
 	}
+
 	if resp.Status == http.StatusPreconditionFailed {
 		return false, nil
 	}
-	if err := resp.CheckStatus(http.StatusOK); err != nil {
-		return false, err
-	}
+
 	return true, nil
 }
 
@@ -164,16 +193,18 @@ func (r *Ref) Push(ctx context.Context, v interface{}) (*Ref, error) {
 	if v == nil {
 		v = ""
 	}
-	resp, err := r.sendWithBody(ctx, "POST", v)
-	if err != nil {
-		return nil, err
+
+	req := &internal.Request{
+		Method: http.MethodPost,
+		Body:   internal.NewJSONEntity(v),
 	}
 	var d struct {
 		Name string `json:"name"`
 	}
-	if err := resp.Unmarshal(http.StatusOK, &d); err != nil {
+	if _, err := r.sendAndUnmarshal(ctx, req, &d); err != nil {
 		return nil, err
 	}
+
 	return r.Child(d.Name), nil
 }
 
@@ -182,11 +213,16 @@ func (r *Ref) Update(ctx context.Context, v map[string]interface{}) error {
 	if len(v) == 0 {
 		return fmt.Errorf("value argument must be a non-empty map")
 	}
-	resp, err := r.sendWithBody(ctx, "PATCH", v, internal.WithQueryParam("print", "silent"))
-	if err != nil {
-		return err
+
+	req := &internal.Request{
+		Method: http.MethodPatch,
+		Body:   internal.NewJSONEntity(v),
+		Opts: []internal.HTTPOption{
+			internal.WithQueryParam("print", "silent"),
+		},
 	}
-	return resp.CheckStatus(http.StatusNoContent)
+	_, err := r.sendAndUnmarshal(ctx, req, nil)
+	return err
 }
 
 // UpdateFn represents a function type that can be passed into Transaction().
@@ -207,28 +243,41 @@ type UpdateFn func(TransactionNode) (interface{}, error)
 // The update function may also force an early abort by returning an error instead of returning a
 // value.
 func (r *Ref) Transaction(ctx context.Context, fn UpdateFn) error {
-	resp, err := r.send(ctx, "GET", internal.WithHeader("X-Firebase-ETag", "true"))
+	req := &internal.Request{
+		Method: http.MethodGet,
+		Opts: []internal.HTTPOption{
+			internal.WithHeader("X-Firebase-ETag", "true"),
+		},
+	}
+	resp, err := r.sendAndUnmarshal(ctx, req, nil)
 	if err != nil {
 		return err
-	} else if err := resp.CheckStatus(http.StatusOK); err != nil {
-		return err
 	}
-	etag := resp.Header.Get("Etag")
 
+	etag := resp.Header.Get("Etag")
 	for i := 0; i < txnRetries; i++ {
 		new, err := fn(&transactionNodeImpl{resp.Body})
 		if err != nil {
 			return err
 		}
-		resp, err = r.sendWithBody(ctx, "PUT", new, internal.WithHeader("If-Match", etag))
+
+		req := &internal.Request{
+			Method: http.MethodPut,
+			Body:   internal.NewJSONEntity(new),
+			Opts: []internal.HTTPOption{
+				internal.WithHeader("If-Match", etag),
+			},
+			SuccessFn: successOrPreconditionFailed,
+		}
+		resp, err = r.sendAndUnmarshal(ctx, req, nil)
 		if err != nil {
 			return err
 		}
+
 		if resp.Status == http.StatusOK {
 			return nil
-		} else if err := resp.CheckStatus(http.StatusPreconditionFailed); err != nil {
-			return err
 		}
+
 		etag = resp.Header.Get("ETag")
 	}
 	return fmt.Errorf("transaction aborted after failed retries")
@@ -236,26 +285,23 @@ func (r *Ref) Transaction(ctx context.Context, fn UpdateFn) error {
 
 // Delete removes this node from the database.
 func (r *Ref) Delete(ctx context.Context) error {
-	resp, err := r.send(ctx, "DELETE")
-	if err != nil {
-		return err
+	req := &internal.Request{
+		Method: http.MethodDelete,
 	}
-	return resp.CheckStatus(http.StatusOK)
+	_, err := r.sendAndUnmarshal(ctx, req, nil)
+	return err
 }
 
-func (r *Ref) send(
-	ctx context.Context,
-	method string,
-	opts ...internal.HTTPOption) (*internal.Response, error) {
-
-	return r.client.send(ctx, method, r.Path, nil, opts...)
+func (r *Ref) sendAndUnmarshal(
+	ctx context.Context, req *internal.Request, v interface{}) (*internal.Response, error) {
+	req.URL = r.Path
+	return r.client.sendAndUnmarshal(ctx, req, v)
 }
 
-func (r *Ref) sendWithBody(
-	ctx context.Context,
-	method string,
-	body interface{},
-	opts ...internal.HTTPOption) (*internal.Response, error) {
+func successOrNotModified(resp *internal.Response) bool {
+	return internal.HasSuccessStatus(resp) || resp.Status == http.StatusNotModified
+}
 
-	return r.client.send(ctx, method, r.Path, internal.NewJSONEntity(body), opts...)
+func successOrPreconditionFailed(resp *internal.Response) bool {
+	return internal.HasSuccessStatus(resp) || resp.Status == http.StatusPreconditionFailed
 }
diff --git a/errorutils/errorutils.go b/errorutils/errorutils.go
new file mode 100644
index 0000000..93a6b07
--- /dev/null
+++ b/errorutils/errorutils.go
@@ -0,0 +1,143 @@
+// Copyright 2020 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 provides functions for checking and handling error conditions.
+package errorutils // import "firebase.google.com/go/v4/errorutils"
+
+import "firebase.google.com/go/v4/internal"
+
+import "net/http"
+
+// IsInvalidArgument checks if the given error was due to an invalid client argument.
+func IsInvalidArgument(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.InvalidArgument)
+}
+
+// IsFailedPrecondition checks if the given error was because a request could not be executed
+// in the current system state, such as deleting a non-empty directory.
+func IsFailedPrecondition(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.FailedPrecondition)
+}
+
+// IsOutOfRange checks if the given error due to an invalid range specified by the client.
+func IsOutOfRange(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.OutOfRange)
+}
+
+// IsUnauthenticated checks if the given error was caused by an unauthenticated request.
+//
+// Unauthenticated requests are due to missing, invalid, or expired OAuth token.
+func IsUnauthenticated(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Unauthenticated)
+}
+
+// IsPermissionDenied checks if the given error was due to a client not having suffificient
+// permissions.
+//
+// This can happen because the OAuth token does not have the right scopes, the client doesn't have
+// permission, or the API has not been enabled for the client project.
+func IsPermissionDenied(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.PermissionDenied)
+}
+
+// IsNotFound checks if the given error was due to a specified resource being not found.
+//
+// This may also occur when the request is rejected by undisclosed reasons, such as whitelisting.
+func IsNotFound(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.NotFound)
+}
+
+// IsConflict checks if the given error was due to a concurrency conflict, such as a
+// read-modify-write conflict.
+//
+// This represents an HTTP 409 Conflict status code, without additional information to distinguish
+// between ABORTED or ALREADY_EXISTS error conditions.
+func IsConflict(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Conflict)
+}
+
+// IsAborted checks if the given error was due to a concurrency conflict, such as a
+// read-modify-write conflict.
+func IsAborted(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Aborted)
+}
+
+// IsAlreadyExists checks if the given error was because a resource that a client tried to create
+// already exists.
+func IsAlreadyExists(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.AlreadyExists)
+}
+
+// IsResourceExhausted checks if the given error was caused by either running out of a quota or
+// reaching a rate limit.
+func IsResourceExhausted(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.ResourceExhausted)
+}
+
+// IsCancelled checks if the given error was due to the client cancelling a request.
+func IsCancelled(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Cancelled)
+}
+
+// IsDataLoss checks if the given error was due to an unrecoverable data loss or corruption.
+//
+// The client should report such errors to the end user.
+func IsDataLoss(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.DataLoss)
+}
+
+// IsUnknown checks if the given error was cuased by an unknown server error.
+//
+// This typically indicates a server bug.
+func IsUnknown(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Unknown)
+}
+
+// IsInternal checks if the given error was due to an internal server error.
+//
+// This typically indicates a server bug.
+func IsInternal(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Internal)
+}
+
+// IsUnavailable checks if the given error was caused by an unavailable service.
+//
+// This typically indicates that the target service is temporarily down.
+func IsUnavailable(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.Unavailable)
+}
+
+// IsDeadlineExceeded checks if the given error was due a request exceeding a deadline.
+//
+// This will happen only if the caller sets a deadline that is shorter than the method's default
+// deadline (i.e. requested deadline is not enough for the server to process the request) and the
+// request did not finish within the deadline.
+func IsDeadlineExceeded(err error) bool {
+	return internal.HasPlatformErrorCode(err, internal.DeadlineExceeded)
+}
+
+// HTTPResponse returns the http.Response instance that caused the given error.
+//
+// If the error was not caused by an HTTP error response, returns nil.
+//
+// Returns a buffered copy of the original response received from the network stack. It is safe to
+// read the response content from the returned http.Response.
+func HTTPResponse(err error) *http.Response {
+	fe, ok := err.(*internal.FirebaseError)
+	if ok {
+		return fe.Response
+	}
+
+	return nil
+}
diff --git a/firebase.go b/firebase.go
index f7cfaee..0a03c24 100644
--- a/firebase.go
+++ b/firebase.go
@@ -15,7 +15,7 @@
 // Package firebase is the entry point to the Firebase Admin SDK. It provides functionality for initializing App
 // instances, which serve as the central entities that provide access to various other Firebase services exposed
 // from the SDK.
-package firebase // import "firebase.google.com/go"
+package firebase // import "firebase.google.com/go/v4"
 
 import (
 	"context"
@@ -25,12 +25,12 @@ import (
 	"os"
 
 	"cloud.google.com/go/firestore"
-	"firebase.google.com/go/auth"
-	"firebase.google.com/go/db"
-	"firebase.google.com/go/iid"
-	"firebase.google.com/go/internal"
-	"firebase.google.com/go/messaging"
-	"firebase.google.com/go/storage"
+	"firebase.google.com/go/v4/auth"
+	"firebase.google.com/go/v4/db"
+	"firebase.google.com/go/v4/iid"
+	"firebase.google.com/go/v4/internal"
+	"firebase.google.com/go/v4/messaging"
+	"firebase.google.com/go/v4/storage"
 	"google.golang.org/api/option"
 	"google.golang.org/api/transport"
 )
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c50a801
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module firebase.google.com/go/v4
+
+go 1.11
+
+require (
+	cloud.google.com/go/firestore v1.1.1
+	cloud.google.com/go/storage v1.0.0
+	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
+	google.golang.org/api v0.17.0
+	google.golang.org/appengine v1.6.1
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..63bcc71
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,179 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3 h1:AVXDdKsrtX33oR9fbCMu/+c1o8Ofjq6Ku/MInaLVg5Y=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go/bigquery v1.0.1 h1:hL+ycaJpVE9M7nLoiXb/Pn10ENE2u+oddxbD8uu0ZVU=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/datastore v1.0.0 h1:Kt+gOPPp2LEPWp8CSfxhsM8ik9CcyE/gYu+0r+RnZvM=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/firestore v1.1.1 h1:vFLWT9tT+SQnfY20DgeNmwh56CSB3kc+Jt16o6Wy8IE=
+cloud.google.com/go/firestore v1.1.1/go.mod h1:ADXYdzUfnr5T2SaB0Of9UXDIjgcRIZ221HQOikRONfE=
+cloud.google.com/go/pubsub v1.0.1 h1:W9tAK3E57P75u0XLLR82LZyw8VpAnhmyTOxW9qzmyj8=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/storage v1.0.0 h1:VV2nUM3wwLLGh9lSABFgZMjInyUbJeaRSE64WuAIQ+4=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024 h1:rBMNdlhTLzJjJSDIjNEXX1Pz3Hmwmz91v+zycvx9PJc=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587 h1:5Uz0rkjCFu9BC9gCRN7EkwVvhNyQgGWb8KNJrPwBoHY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191206204035-259af5ff87bd h1:Zc7EU2PqpsNeIfOoVA7hvQX4cS3YDJEs5KlfatT3hLo=
+golang.org/x/tools v0.0.0-20191206204035-259af5ff87bd/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0 h1:0q95w+VuFtv4PAx4PZVQdBMmYbaCHbnfKaEiDIcVyag=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191206224255-0243a4be9c8f h1:naitw5DILWPQvG0oG04mR9jF8fmKpRdW3E3zzKA4D0Y=
+google.golang.org/genproto v0.0.0-20191206224255-0243a4be9c8f/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.27.0 h1:rRYRFMVgRv6E0D70Skyfsr28tDXIuuPZyWGMPdMcnXg=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
diff --git a/iid/iid.go b/iid/iid.go
index 19362dc..4527d6f 100644
--- a/iid/iid.go
+++ b/iid/iid.go
@@ -13,90 +13,89 @@
 // limitations under the License.
 
 // Package iid contains functions for deleting instance IDs from Firebase projects.
-package iid // import "firebase.google.com/go/iid"
+package iid // import "firebase.google.com/go/v4/iid"
 
 import (
 	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/internal"
 )
 
 const iidEndpoint = "https://console.firebase.google.com/v1"
 
-const (
-	invalidArgument        = "invalid-argument"
-	unauthorized           = "unauthorized"
-	insufficientPermission = "insufficient-permission"
-	notFound               = "instance-id-not-found"
-	alreadyDeleted         = "instance-id-already-deleted"
-	tooManyRequests        = "too-many-requests"
-	internalError          = "internal-error"
-	serverUnavailable      = "server-unavailable"
-	unknown                = "unknown-error"
-)
-
-var errorCodes = map[int]struct {
-	code, message string
-}{
-	http.StatusBadRequest:   {invalidArgument, "malformed instance id argument"},
-	http.StatusUnauthorized: {insufficientPermission, "request not authorized"},
-	http.StatusForbidden: {
-		insufficientPermission,
-		"project does not match instance ID or the client does not have sufficient privileges",
-	},
-	http.StatusNotFound:            {notFound, "failed to find the instance id"},
-	http.StatusConflict:            {alreadyDeleted, "already deleted"},
-	http.StatusTooManyRequests:     {tooManyRequests, "request throttled out by the backend server"},
-	http.StatusInternalServerError: {internalError, "internal server error"},
-	http.StatusServiceUnavailable:  {serverUnavailable, "backend servers are over capacity"},
+var errorMessages = map[int]string{
+	http.StatusBadRequest:          "malformed instance id argument",
+	http.StatusUnauthorized:        "request not authorized",
+	http.StatusForbidden:           "project does not match instance ID or the client does not have sufficient privileges",
+	http.StatusNotFound:            "failed to find the instance id",
+	http.StatusConflict:            "already deleted",
+	http.StatusTooManyRequests:     "request throttled out by the backend server",
+	http.StatusInternalServerError: "internal server error",
+	http.StatusServiceUnavailable:  "backend servers are over capacity",
 }
 
 // IsInvalidArgument checks if the given error was due to an invalid instance ID argument.
+//
+// Deprecated. Use errorutils.IsInvalidArgument() function instead.
 func IsInvalidArgument(err error) bool {
-	return internal.HasErrorCode(err, invalidArgument)
+	return errorutils.IsInvalidArgument(err)
 }
 
 // IsInsufficientPermission checks if the given error was due to the request not having the
 // required authorization. This could be due to the client not having the required permission
 // or the specified instance ID not matching the target Firebase project.
+//
+// Deprecated. Use errorutils.IsUnauthenticated() or errorutils.IsPermissionDenied() instead.
 func IsInsufficientPermission(err error) bool {
-	return internal.HasErrorCode(err, insufficientPermission)
+	return errorutils.IsUnauthenticated(err) || errorutils.IsPermissionDenied(err)
 }
 
 // IsNotFound checks if the given error was due to a non existing instance ID.
 func IsNotFound(err error) bool {
-	return internal.HasErrorCode(err, notFound)
+	return errorutils.IsNotFound(err)
 }
 
 // IsAlreadyDeleted checks if the given error was due to the instance ID being already deleted from
 // the project.
+//
+// Deprecated. Use errorutils.IsConflict() function instead.
 func IsAlreadyDeleted(err error) bool {
-	return internal.HasErrorCode(err, alreadyDeleted)
+	return errorutils.IsConflict(err)
 }
 
 // IsTooManyRequests checks if the given error was due to the client sending too many requests
 // causing a server quota to exceed.
+//
+// Deprecated. Use errorutils.IsResourceExhausted() function instead.
 func IsTooManyRequests(err error) bool {
-	return internal.HasErrorCode(err, tooManyRequests)
+	return errorutils.IsResourceExhausted(err)
 }
 
 // IsInternal checks if the given error was due to an internal server error.
+//
+// Deprecated. Use errorutils.IsInternal() function instead.
 func IsInternal(err error) bool {
-	return internal.HasErrorCode(err, internalError)
+	return errorutils.IsInternal(err)
 }
 
 // IsServerUnavailable checks if the given error was due to the backend server being temporarily
 // unavailable.
+//
+// Deprecated. Use errorutils.IsUnavailable() function instead.
 func IsServerUnavailable(err error) bool {
-	return internal.HasErrorCode(err, serverUnavailable)
+	return errorutils.IsUnavailable(err)
 }
 
 // IsUnknown checks if the given error was due to unknown error returned by the backend server.
+//
+// Deprecated. Use errorutils.IsUnknown() function instead.
 func IsUnknown(err error) bool {
-	return internal.HasErrorCode(err, unknown)
+	return errorutils.IsUnknown(err)
 }
 
 // Client is the interface for the Firebase Instance ID service.
@@ -121,6 +120,7 @@ func NewClient(ctx context.Context, c *internal.InstanceIDConfig) (*Client, erro
 		return nil, err
 	}
 
+	hc.CreateErrFn = createError
 	return &Client{
 		endpoint: iidEndpoint,
 		client:   hc,
@@ -140,16 +140,17 @@ func (c *Client) DeleteInstanceID(ctx context.Context, iid string) error {
 	}
 
 	url := fmt.Sprintf("%s/project/%s/instanceId/%s", c.endpoint, c.project, iid)
-	resp, err := c.client.Do(ctx, &internal.Request{Method: http.MethodDelete, URL: url})
-	if err != nil {
-		return err
-	}
+	_, err := c.client.Do(ctx, &internal.Request{Method: http.MethodDelete, URL: url})
+	return err
+}
 
-	if info, ok := errorCodes[resp.Status]; ok {
-		return internal.Errorf(info.code, "instance id %q: %s", iid, info.message)
-	}
-	if err := resp.CheckStatus(http.StatusOK); err != nil {
-		return internal.Error(unknown, err.Error())
+func createError(resp *internal.Response) error {
+	err := internal.NewFirebaseError(resp)
+	if msg, ok := errorMessages[resp.Status]; ok {
+		requestPath := resp.LowLevelResponse().Request.URL.Path
+		idx := strings.LastIndex(requestPath, "/")
+		err.String = fmt.Sprintf("instance id %q: %s", requestPath[idx+1:], msg)
 	}
-	return nil
+
+	return err
 }
diff --git a/integration/internal/internal.go b/integration/internal/internal.go
index a5cd7af..3a7948c 100644
--- a/integration/internal/internal.go
+++ b/integration/internal/internal.go
@@ -18,14 +18,13 @@ package internal
 import (
 	"context"
 	"encoding/json"
-	"go/build"
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
 	"strings"
 
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/internal"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 	"google.golang.org/api/transport"
 )
@@ -35,7 +34,7 @@ const apiKeyPath = "integration_apikey.txt"
 
 // Resource returns the absolute path to the specified test resource file.
 func Resource(name string) string {
-	p := []string{build.Default.GOPATH, "src", "firebase.google.com", "go", "testdata", name}
+	p := []string{"..", "..", "testdata", name}
 	return filepath.Join(p...)
 }
 
diff --git a/internal/errors.go b/internal/errors.go
new file mode 100644
index 0000000..e209d15
--- /dev/null
+++ b/internal/errors.go
@@ -0,0 +1,197 @@
+// Copyright 2020 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 internal
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"syscall"
+)
+
+// ErrorCode represents the platform-wide error codes that can be raised by
+// Admin SDK APIs.
+type ErrorCode string
+
+const (
+	// InvalidArgument is a OnePlatform error code.
+	InvalidArgument ErrorCode = "INVALID_ARGUMENT"
+
+	// FailedPrecondition is a OnePlatform error code.
+	FailedPrecondition ErrorCode = "FAILED_PRECONDITION"
+
+	// OutOfRange is a OnePlatform error code.
+	OutOfRange ErrorCode = "OUT_OF_RANGE"
+
+	// Unauthenticated is a OnePlatform error code.
+	Unauthenticated ErrorCode = "UNAUTHENTICATED"
+
+	// PermissionDenied is a OnePlatform error code.
+	PermissionDenied ErrorCode = "PERMISSION_DENIED"
+
+	// NotFound is a OnePlatform error code.
+	NotFound ErrorCode = "NOT_FOUND"
+
+	// Conflict is a custom error code that represents HTTP 409 responses.
+	//
+	// OnePlatform APIs typically respond with ABORTED or ALREADY_EXISTS explicitly. But a few
+	// old APIs send HTTP 409 Conflict without any additional details to distinguish between the two
+	// cases. For these we currently use this error code. As more APIs adopt OnePlatform conventions
+	// this will become less important.
+	Conflict ErrorCode = "CONFLICT"
+
+	// Aborted is a OnePlatform error code.
+	Aborted ErrorCode = "ABORTED"
+
+	// AlreadyExists is a OnePlatform error code.
+	AlreadyExists ErrorCode = "ALREADY_EXISTS"
+
+	// ResourceExhausted is a OnePlatform error code.
+	ResourceExhausted ErrorCode = "RESOURCE_EXHAUSTED"
+
+	// Cancelled is a OnePlatform error code.
+	Cancelled ErrorCode = "CANCELLED"
+
+	// DataLoss is a OnePlatform error code.
+	DataLoss ErrorCode = "DATA_LOSS"
+
+	// Unknown is a OnePlatform error code.
+	Unknown ErrorCode = "UNKNOWN"
+
+	// Internal is a OnePlatform error code.
+	Internal ErrorCode = "INTERNAL"
+
+	// Unavailable is a OnePlatform error code.
+	Unavailable ErrorCode = "UNAVAILABLE"
+
+	// DeadlineExceeded is a OnePlatform error code.
+	DeadlineExceeded ErrorCode = "DEADLINE_EXCEEDED"
+)
+
+// FirebaseError is an error type containing an error code string.
+type FirebaseError struct {
+	ErrorCode ErrorCode
+	String    string
+	Response  *http.Response
+	Ext       map[string]interface{}
+}
+
+func (fe *FirebaseError) Error() string {
+	return fe.String
+}
+
+// HasPlatformErrorCode checks if the given error contains a specific error code.
+func HasPlatformErrorCode(err error, code ErrorCode) bool {
+	fe, ok := err.(*FirebaseError)
+	return ok && fe.ErrorCode == code
+}
+
+var httpStatusToErrorCodes = map[int]ErrorCode{
+	http.StatusBadRequest:          InvalidArgument,
+	http.StatusUnauthorized:        Unauthenticated,
+	http.StatusForbidden:           PermissionDenied,
+	http.StatusNotFound:            NotFound,
+	http.StatusConflict:            Conflict,
+	http.StatusTooManyRequests:     ResourceExhausted,
+	http.StatusInternalServerError: Internal,
+	http.StatusServiceUnavailable:  Unavailable,
+}
+
+// NewFirebaseError creates a new error from the given HTTP response.
+func NewFirebaseError(resp *Response) *FirebaseError {
+	code, ok := httpStatusToErrorCodes[resp.Status]
+	if !ok {
+		code = Unknown
+	}
+
+	return &FirebaseError{
+		ErrorCode: code,
+		String:    fmt.Sprintf("unexpected http response with status: %d\n%s", resp.Status, string(resp.Body)),
+		Response:  resp.LowLevelResponse(),
+		Ext:       make(map[string]interface{}),
+	}
+}
+
+// NewFirebaseErrorOnePlatform parses the response payload as a GCP error response
+// and create an error from the details extracted.
+//
+// If the response failes to parse, or otherwise doesn't provide any useful details
+// NewFirebaseErrorOnePlatform creates an error with some sensible defaults.
+func NewFirebaseErrorOnePlatform(resp *Response) *FirebaseError {
+	base := NewFirebaseError(resp)
+
+	var gcpError struct {
+		Error struct {
+			Status  string `json:"status"`
+			Message string `json:"message"`
+		} `json:"error"`
+	}
+	json.Unmarshal(resp.Body, &gcpError) // ignore any json parse errors at this level
+	if gcpError.Error.Status != "" {
+		base.ErrorCode = ErrorCode(gcpError.Error.Status)
+	}
+
+	if gcpError.Error.Message != "" {
+		base.String = gcpError.Error.Message
+	}
+
+	return base
+}
+
+func newFirebaseErrorTransport(err error) *FirebaseError {
+	var code ErrorCode
+	var msg string
+	if os.IsTimeout(err) {
+		code = DeadlineExceeded
+		msg = fmt.Sprintf("timed out while making an http call: %v", err)
+	} else if isConnectionRefused(err) {
+		code = Unavailable
+		msg = fmt.Sprintf("failed to establish a connection: %v", err)
+	} else {
+		code = Unknown
+		msg = fmt.Sprintf("unknown error while making an http call: %v", err)
+	}
+
+	return &FirebaseError{
+		ErrorCode: code,
+		String:    msg,
+		Ext:       make(map[string]interface{}),
+	}
+}
+
+// isConnectionRefused attempts to determine if the given error was caused by a failure to establish a
+// connection.
+//
+// A net.OpError where the Op field is set to "dial" or "read" is considered a connection refused
+// error. Similarly an ECONNREFUSED error code (Linux-specific) is also considered a connection
+// refused error.
+func isConnectionRefused(err error) bool {
+	switch t := err.(type) {
+	case *url.Error:
+		return isConnectionRefused(t.Err)
+	case *net.OpError:
+		if t.Op == "dial" || t.Op == "read" {
+			return true
+		}
+		return isConnectionRefused(t.Err)
+	case syscall.Errno:
+		return t == syscall.ECONNREFUSED
+	}
+
+	return false
+}
diff --git a/internal/http_client.go b/internal/http_client.go
index de03a8e..000cba3 100644
--- a/internal/http_client.go
+++ b/internal/http_client.go
@@ -41,7 +41,6 @@ import (
 type HTTPClient struct {
 	Client      *http.Client
 	RetryConfig *RetryConfig
-	ErrParser   ErrorParser // Deprecated. Use CreateErrFn instead.
 	CreateErrFn CreateErrFn
 	SuccessFn   SuccessFn
 	Opts        []HTTPOption
@@ -101,10 +100,28 @@ type Request struct {
 
 // Response contains information extracted from an HTTP response.
 type Response struct {
-	Status    int
-	Header    http.Header
-	Body      []byte
-	errParser ErrorParser
+	Status int
+	Header http.Header
+	Body   []byte
+	resp   *http.Response
+}
+
+// LowLevelResponse returns an http.Response that represents the underlying low-level HTTP
+// response.
+//
+// This always returns a buffered copy of the original HTTP response. Body can be read from the
+// returned response with no impact on the underlying HTTP connection. Closing the Body on the
+// returned response is a No-op.
+func (r *Response) LowLevelResponse() *http.Response {
+	// If the Response instance was initialized manually (as is the case when parsing batch
+	// responses) the resp field may be nil.
+	if r.resp == nil {
+		return nil
+	}
+
+	resp := *r.resp
+	resp.Body = ioutil.NopCloser(bytes.NewBuffer(r.Body))
+	return &resp
 }
 
 // Do executes the given Request, and returns a Response.
@@ -117,20 +134,23 @@ type Response struct {
 // used as the default error function.
 func (c *HTTPClient) Do(ctx context.Context, req *Request) (*Response, error) {
 	var result *attemptResult
-	var err error
 
 	for retries := 0; ; retries++ {
-		result, err = c.attempt(ctx, req, retries)
+		hr, err := req.buildHTTPRequest(c.Opts)
 		if err != nil {
 			return nil, err
 		}
+
+		result = c.attempt(ctx, hr, retries)
 		if !result.Retry {
 			break
 		}
+
 		if err = result.waitForRetry(ctx); err != nil {
 			return nil, err
 		}
 	}
+
 	return c.handleResult(req, result)
 }
 
@@ -155,12 +175,7 @@ func (c *HTTPClient) DoAndUnmarshal(ctx context.Context, req *Request, v interfa
 	return resp, nil
 }
 
-func (c *HTTPClient) attempt(ctx context.Context, req *Request, retries int) (*attemptResult, error) {
-	hr, err := req.buildHTTPRequest(c.Opts)
-	if err != nil {
-		return nil, err
-	}
-
+func (c *HTTPClient) attempt(ctx context.Context, hr *http.Request, retries int) *attemptResult {
 	resp, err := c.Client.Do(hr.WithContext(ctx))
 	result := &attemptResult{}
 	if err != nil {
@@ -168,7 +183,7 @@ func (c *HTTPClient) attempt(ctx context.Context, req *Request, retries int) (*a
 	} else {
 		// Read the response body here forcing any I/O errors to occur so that retry logic will
 		// cover them as well.
-		ir, err := newResponse(resp, c.ErrParser)
+		ir, err := newResponse(resp)
 		result.Resp = ir
 		result.Err = err
 	}
@@ -181,12 +196,13 @@ func (c *HTTPClient) attempt(ctx context.Context, req *Request, retries int) (*a
 		result.RetryAfter = delay
 		result.Retry = retry
 	}
-	return result, nil
+
+	return result
 }
 
 func (c *HTTPClient) handleResult(req *Request, result *attemptResult) (*Response, error) {
 	if result.Err != nil {
-		return nil, fmt.Errorf("error while making http call: %v", result.Err)
+		return nil, newFirebaseErrorTransport(result.Err)
 	}
 
 	if !c.success(req, result.Resp) {
@@ -202,18 +218,18 @@ func (c *HTTPClient) success(req *Request, resp *Response) bool {
 		successFn = req.SuccessFn
 	} else if c.SuccessFn != nil {
 		successFn = c.SuccessFn
+	} else {
+		successFn = HasSuccessStatus
 	}
 
-	if successFn != nil {
-		return successFn(resp)
-	}
-
-	// TODO: Default to HasSuccessStatusCode
-	return true
+	return successFn(resp)
 }
 
 func (c *HTTPClient) newError(req *Request, resp *Response) error {
-	createErr := CreatePlatformError
+	createErr := func(r *Response) error {
+		return NewFirebaseErrorOnePlatform(r)
+	}
+
 	if req.CreateErrFn != nil {
 		createErr = req.CreateErrFn
 	} else if c.CreateErrFn != nil {
@@ -286,60 +302,21 @@ func (e *jsonEntity) Mime() string {
 	return "application/json"
 }
 
-func newResponse(resp *http.Response, errParser ErrorParser) (*Response, error) {
+func newResponse(resp *http.Response) (*Response, error) {
 	defer resp.Body.Close()
 	b, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
 		return nil, err
 	}
+
 	return &Response{
-		Status:    resp.StatusCode,
-		Body:      b,
-		Header:    resp.Header,
-		errParser: errParser,
+		Status: resp.StatusCode,
+		Body:   b,
+		Header: resp.Header,
+		resp:   resp,
 	}, nil
 }
 
-// CheckStatus checks whether the Response status code has the given HTTP status code.
-//
-// Returns an error if the status code does not match. If an ErrorParser is specified, uses that to
-// construct the returned error message. Otherwise includes the full response body in the error.
-//
-// Deprecated. Directly verify the Status field on the Response instead.
-func (r *Response) CheckStatus(want int) error {
-	if r.Status == want {
-		return nil
-	}
-
-	var msg string
-	if r.errParser != nil {
-		msg = r.errParser(r.Body)
-	}
-	if msg == "" {
-		msg = string(r.Body)
-	}
-	return fmt.Errorf("http error status: %d; reason: %s", r.Status, msg)
-}
-
-// Unmarshal checks if the Response has the given HTTP status code, and if so unmarshals the
-// response body into the variable pointed by v.
-//
-// Unmarshal uses https://golang.org/pkg/encoding/json/#Unmarshal internally, and hence v has the
-// same requirements as the json package.
-//
-// Deprecated. Use DoAndUnmarshal function instead.
-func (r *Response) Unmarshal(want int, v interface{}) error {
-	if err := r.CheckStatus(want); err != nil {
-		return err
-	}
-	return json.Unmarshal(r.Body, v)
-}
-
-// ErrorParser is a function that is used to construct custom error messages.
-//
-// Deprecated. Use SuccessFn and CreateErrFn instead.
-type ErrorParser func([]byte) string
-
 // HTTPOption is an additional parameter that can be specified to customize an outgoing request.
 type HTTPOption func(*http.Request)
 
@@ -376,33 +353,6 @@ func HasSuccessStatus(r *Response) bool {
 	return r.Status >= http.StatusOK && r.Status < http.StatusNotModified
 }
 
-// CreatePlatformError parses the response payload as a GCP error response
-// and create an error from the details extracted.
-//
-// If the response failes to parse, or otherwise doesn't provide any useful details
-// CreatePlatformError creates an error with some sensible defaults.
-func CreatePlatformError(resp *Response) error {
-	var gcpError struct {
-		Error struct {
-			Status  string `json:"status"`
-			Message string `json:"message"`
-		} `json:"error"`
-	}
-	json.Unmarshal(resp.Body, &gcpError) // ignore any json parse errors at this level
-	code := gcpError.Error.Status
-	if code == "" {
-		code = "UNKNOWN"
-	}
-
-	message := gcpError.Error.Message
-	if message == "" {
-		message = fmt.Sprintf(
-			"unexpected http response with status: %d; body: %s", resp.Status, string(resp.Body))
-	}
-
-	return Error(code, message)
-}
-
 // RetryConfig specifies how the HTTPClient should retry failing HTTP requests.
 //
 // A request is never retried more than MaxRetries times. If CheckForRetry is nil, all network
diff --git a/internal/internal.go b/internal/internal.go
index 8edc7e4..b667001 100644
--- a/internal/internal.go
+++ b/internal/internal.go
@@ -13,10 +13,9 @@
 // limitations under the License.
 
 // Package internal contains functionality that is only accessible from within the Admin SDK.
-package internal // import "firebase.google.com/go/internal"
+package internal // import "firebase.google.com/go/v4/internal"
 
 import (
-	"fmt"
 	"time"
 
 	"golang.org/x/oauth2"
@@ -74,35 +73,6 @@ type MessagingConfig struct {
 	Version   string
 }
 
-// FirebaseError is an error type containing an error code string.
-type FirebaseError struct {
-	Code   string
-	String string
-}
-
-func (fe *FirebaseError) Error() string {
-	return fe.String
-}
-
-// HasErrorCode checks if the given error contain a specific error code.
-func HasErrorCode(err error, code string) bool {
-	fe, ok := err.(*FirebaseError)
-	return ok && fe.Code == code
-}
-
-// Error creates a new FirebaseError from the specified error code and message.
-func Error(code string, msg string) *FirebaseError {
-	return &FirebaseError{
-		Code:   code,
-		String: msg,
-	}
-}
-
-// Errorf creates a new FirebaseError from the specified error code and message.
-func Errorf(code string, msg string, args ...interface{}) *FirebaseError {
-	return Error(code, fmt.Sprintf(msg, args...))
-}
-
 // MockTokenSource is a TokenSource implementation that can be used for testing.
 type MockTokenSource struct {
 	AccessToken string
diff --git a/messaging/messaging.go b/messaging/messaging.go
index aabfce3..cd0dd52 100644
--- a/messaging/messaging.go
+++ b/messaging/messaging.go
@@ -14,7 +14,7 @@
 
 // Package messaging contains functions for sending messages and managing
 // device subscriptions with Firebase Cloud Messaging (FCM).
-package messaging // import "firebase.google.com/go/messaging"
+package messaging // import "firebase.google.com/go/v4/messaging"
 
 import (
 	"context"
@@ -27,7 +27,7 @@ import (
 	"strings"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/transport"
 )
 
@@ -39,71 +39,20 @@ const (
 	apiFormatVersionHeader = "X-GOOG-API-FORMAT-VERSION"
 	apiFormatVersion       = "2"
 
-	internalError                  = "internal-error"
-	invalidAPNSCredentials         = "invalid-apns-credentials"
-	invalidArgument                = "invalid-argument"
-	messageRateExceeded            = "message-rate-exceeded"
-	mismatchedCredential           = "mismatched-credential"
-	registrationTokenNotRegistered = "registration-token-not-registered"
-	serverUnavailable              = "server-unavailable"
-	tooManyTopics                  = "too-many-topics"
-	unknownError                   = "unknown-error"
+	apnsAuthError       = "APNS_AUTH_ERROR"
+	internalError       = "INTERNAL"
+	thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR"
+	invalidArgument     = "INVALID_ARGUMENT"
+	quotaExceeded       = "QUOTA_EXCEEDED"
+	senderIDMismatch    = "SENDER_ID_MISMATCH"
+	unregistered        = "UNREGISTERED"
+	unavailable         = "UNAVAILABLE"
 
 	rfc3339Zulu = "2006-01-02T15:04:05.000000000Z"
 )
 
 var (
 	topicNamePattern = regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$")
-
-	fcmErrorCodes = map[string]struct{ Code, Msg string }{
-		// FCM v1 canonical error codes
-		"NOT_FOUND": {
-			registrationTokenNotRegistered,
-			"app instance has been unregistered; code: " + registrationTokenNotRegistered,
-		},
-		"PERMISSION_DENIED": {
-			mismatchedCredential,
-			"sender id does not match registration token; code: " + mismatchedCredential,
-		},
-		"RESOURCE_EXHAUSTED": {
-			messageRateExceeded,
-			"messaging service quota exceeded; code: " + messageRateExceeded,
-		},
-		"UNAUTHENTICATED": {
-			invalidAPNSCredentials,
-			"apns certificate or auth key was invalid; code: " + invalidAPNSCredentials,
-		},
-
-		// FCM v1 new error codes
-		"APNS_AUTH_ERROR": {
-			invalidAPNSCredentials,
-			"apns certificate or auth key was invalid; code: " + invalidAPNSCredentials,
-		},
-		"INTERNAL": {
-			internalError,
-			"backend servers encountered an unknown internl error; code: " + internalError,
-		},
-		"INVALID_ARGUMENT": {
-			invalidArgument,
-			"request contains an invalid argument; code: " + invalidArgument,
-		},
-		"SENDER_ID_MISMATCH": {
-			mismatchedCredential,
-			"sender id does not match registration token; code: " + mismatchedCredential,
-		},
-		"QUOTA_EXCEEDED": {
-			messageRateExceeded,
-			"messaging service quota exceeded; code: " + messageRateExceeded,
-		},
-		"UNAVAILABLE": {
-			serverUnavailable,
-			"backend servers are temporarily unavailable; code: " + serverUnavailable,
-		},
-		"UNREGISTERED": {
-			registrationTokenNotRegistered,
-			"app instance has been unregistered; code: " + registrationTokenNotRegistered,
-		},
-	}
 )
 
 // Message to be sent via Firebase Cloud Messaging.
@@ -554,7 +503,7 @@ type WebpushConfig struct {
 	Headers      map[string]string    `json:"headers,omitempty"`
 	Data         map[string]string    `json:"data,omitempty"`
 	Notification *WebpushNotification `json:"notification,omitempty"`
-	FcmOptions   *WebpushFcmOptions   `json:"fcm_options,omitempty"`
+	FCMOptions   *WebpushFCMOptions   `json:"fcm_options,omitempty"`
 }
 
 // WebpushNotificationAction represents an action that can be performed upon receiving a WebPush notification.
@@ -660,8 +609,8 @@ func (n *WebpushNotification) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-// WebpushFcmOptions contains additional options for features provided by the FCM web SDK.
-type WebpushFcmOptions struct {
+// WebpushFCMOptions contains additional options for features provided by the FCM web SDK.
+type WebpushFCMOptions struct {
 	Link string `json:"link,omitempty"`
 }
 
@@ -939,7 +888,6 @@ type fcmClient struct {
 func newFCMClient(hc *http.Client, conf *internal.MessagingConfig, endpoint string) *fcmClient {
 	client := internal.WithDefaultRetryConfig(hc)
 	client.CreateErrFn = handleFCMError
-	client.SuccessFn = internal.HasSuccessStatus
 
 	version := fmt.Sprintf("fire-admin-go/%s", conf.Version)
 	client.Opts = []internal.HTTPOption{
@@ -998,52 +946,95 @@ func (c *fcmClient) makeSendRequest(ctx context.Context, req *fcmRequest) (strin
 
 // IsInternal checks if the given error was due to an internal server error.
 func IsInternal(err error) bool {
-	return internal.HasErrorCode(err, internalError)
+	return hasMessagingErrorCode(err, internalError)
 }
 
 // IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth
 // key.
+//
+// Deprecated. Use IsThirdPartyAuthError().
 func IsInvalidAPNSCredentials(err error) bool {
-	return internal.HasErrorCode(err, invalidAPNSCredentials)
+	return IsThirdPartyAuthError(err)
+}
+
+// IsThirdPartyAuthError checks if the given error was due to invalid APNS certificate or auth
+// key.
+func IsThirdPartyAuthError(err error) bool {
+	return hasMessagingErrorCode(err, thirdPartyAuthError) || hasMessagingErrorCode(err, apnsAuthError)
 }
 
 // IsInvalidArgument checks if the given error was due to an invalid argument in the request.
 func IsInvalidArgument(err error) bool {
-	return internal.HasErrorCode(err, invalidArgument)
+	return hasMessagingErrorCode(err, invalidArgument)
 }
 
 // IsMessageRateExceeded checks if the given error was due to the client exceeding a quota.
+//
+// Deprecated. Use IsQuotaExceeded().
 func IsMessageRateExceeded(err error) bool {
-	return internal.HasErrorCode(err, messageRateExceeded)
+	return IsQuotaExceeded(err)
+}
+
+// IsQuotaExceeded checks if the given error was due to the client exceeding a quota.
+func IsQuotaExceeded(err error) bool {
+	return hasMessagingErrorCode(err, quotaExceeded)
 }
 
 // IsMismatchedCredential checks if the given error was due to an invalid credential or permission
 // error.
+//
+// Deprecated. Use IsSenderIDMismatch().
 func IsMismatchedCredential(err error) bool {
-	return internal.HasErrorCode(err, mismatchedCredential)
+	return IsSenderIDMismatch(err)
+}
+
+// IsSenderIDMismatch checks if the given error was due to an invalid credential or permission
+// error.
+func IsSenderIDMismatch(err error) bool {
+	return hasMessagingErrorCode(err, senderIDMismatch)
 }
 
 // IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that
 // became invalid.
+//
+// Deprecated. Use IsUnregistered().
 func IsRegistrationTokenNotRegistered(err error) bool {
-	return internal.HasErrorCode(err, registrationTokenNotRegistered)
+	return IsUnregistered(err)
+}
+
+// IsUnregistered checks if the given error was due to a registration token that
+// became invalid.
+func IsUnregistered(err error) bool {
+	return hasMessagingErrorCode(err, unregistered)
 }
 
 // IsServerUnavailable checks if the given error was due to the backend server being temporarily
 // unavailable.
+//
+// Deprecated. Use IsUnavailable().
 func IsServerUnavailable(err error) bool {
-	return internal.HasErrorCode(err, serverUnavailable)
+	return IsUnavailable(err)
+}
+
+// IsUnavailable checks if the given error was due to the backend server being temporarily
+// unavailable.
+func IsUnavailable(err error) bool {
+	return hasMessagingErrorCode(err, unavailable)
 }
 
 // IsTooManyTopics checks if the given error was due to the client exceeding the allowed number
 // of topics.
+//
+// Deprecated. Always returns false.
 func IsTooManyTopics(err error) bool {
-	return internal.HasErrorCode(err, tooManyTopics)
+	return false
 }
 
 // IsUnknown checks if the given error was due to unknown error returned by the backend server.
+//
+// Deprecated. Always returns false.
 func IsUnknown(err error) bool {
-	return internal.HasErrorCode(err, unknownError)
+	return false
 }
 
 type fcmRequest struct {
@@ -1055,10 +1046,8 @@ type fcmResponse struct {
 	Name string `json:"name"`
 }
 
-type fcmError struct {
+type fcmErrorResponse struct {
 	Error struct {
-		Status  string `json:"status"`
-		Message string `json:"message"`
 		Details []struct {
 			Type      string `json:"@type"`
 			ErrorCode string `json:"errorCode"`
@@ -1067,29 +1056,25 @@ type fcmError struct {
 }
 
 func handleFCMError(resp *internal.Response) error {
-	var fe fcmError
+	base := internal.NewFirebaseErrorOnePlatform(resp)
+	var fe fcmErrorResponse
 	json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level
-	var serverCode string
 	for _, d := range fe.Error.Details {
 		if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" {
-			serverCode = d.ErrorCode
+			base.Ext["messagingErrorCode"] = d.ErrorCode
 			break
 		}
 	}
-	if serverCode == "" {
-		serverCode = fe.Error.Status
-	}
 
-	var clientCode, msg string
-	info, ok := fcmErrorCodes[serverCode]
-	if ok {
-		clientCode, msg = info.Code, info.Msg
-	} else {
-		clientCode = unknownError
-		msg = fmt.Sprintf("server responded with an unknown error; response: %s", string(resp.Body))
-	}
-	if fe.Error.Message != "" {
-		msg += "; details: " + fe.Error.Message
+	return base
+}
+
+func hasMessagingErrorCode(err error, code string) bool {
+	fe, ok := err.(*internal.FirebaseError)
+	if !ok {
+		return false
 	}
-	return internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg)
+
+	got, ok := fe.Ext["messagingErrorCode"]
+	return ok && got == code
 }
diff --git a/messaging/messaging_batch.go b/messaging/messaging_batch.go
index cb4e848..b291dde 100644
--- a/messaging/messaging_batch.go
+++ b/messaging/messaging_batch.go
@@ -28,7 +28,7 @@ import (
 	"net/http"
 	"net/textproto"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 const maxMessages = 500
diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go
index f25c437..e69dd65 100644
--- a/messaging/messaging_utils.go
+++ b/messaging/messaging_utils.go
@@ -222,8 +222,8 @@ func validateWebpushConfig(webpush *WebpushConfig) error {
 			return fmt.Errorf("multiple specifications for the key %q", k)
 		}
 	}
-	if webpush.FcmOptions != nil {
-		link := webpush.FcmOptions.Link
+	if webpush.FCMOptions != nil {
+		link := webpush.FCMOptions.Link
 		p, err := url.ParseRequestURI(link)
 		if err != nil {
 			return fmt.Errorf("invalid link URL: %q", link)
diff --git a/messaging/topic_mgt.go b/messaging/topic_mgt.go
index 6252cdb..15a2978 100644
--- a/messaging/topic_mgt.go
+++ b/messaging/topic_mgt.go
@@ -21,7 +21,7 @@ import (
 	"net/http"
 	"strings"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 const (
@@ -30,25 +30,6 @@ const (
 	iidUnsubscribe = "batchRemove"
 )
 
-var iidErrorCodes = map[string]struct{ Code, Msg string }{
-	"INVALID_ARGUMENT": {
-		invalidArgument,
-		"request contains an invalid argument; code: " + invalidArgument,
-	},
-	"NOT_FOUND": {
-		registrationTokenNotRegistered,
-		"request contains an invalid argument; code: " + registrationTokenNotRegistered,
-	},
-	"INTERNAL": {
-		internalError,
-		"server encountered an internal error; code: " + internalError,
-	},
-	"TOO_MANY_TOPICS": {
-		tooManyTopics,
-		"client exceeded the number of allowed topics; code: " + tooManyTopics,
-	},
-}
-
 // TopicManagementResponse is the result produced by topic management operations.
 //
 // TopicManagementResponse provides an overview of how many input tokens were successfully handled,
@@ -67,14 +48,7 @@ func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse {
 			tmr.SuccessCount++
 		} else {
 			tmr.FailureCount++
-			code := res["error"].(string)
-			info, ok := iidErrorCodes[code]
-			var reason string
-			if ok {
-				reason = info.Msg
-			} else {
-				reason = unknownError
-			}
+			reason := res["error"].(string)
 			tmr.Errors = append(tmr.Errors, &ErrorInfo{
 				Index:  idx,
 				Reason: reason,
@@ -92,7 +66,6 @@ type iidClient struct {
 func newIIDClient(hc *http.Client) *iidClient {
 	client := internal.WithDefaultRetryConfig(hc)
 	client.CreateErrFn = handleIIDError
-	client.SuccessFn = internal.HasSuccessStatus
 	client.Opts = []internal.HTTPOption{internal.WithHeader("access_token_auth", "true")}
 	return &iidClient{
 		iidEndpoint: iidEndpoint,
@@ -134,7 +107,7 @@ type iidResponse struct {
 	Results []map[string]interface{} `json:"results"`
 }
 
-type iidError struct {
+type iidErrorResponse struct {
 	Error string `json:"error"`
 }
 
@@ -176,15 +149,12 @@ func (c *iidClient) makeTopicManagementRequest(ctx context.Context, req *iidRequ
 }
 
 func handleIIDError(resp *internal.Response) error {
-	var ie iidError
+	base := internal.NewFirebaseError(resp)
+	var ie iidErrorResponse
 	json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level
-	var clientCode, msg string
-	info, ok := iidErrorCodes[ie.Error]
-	if ok {
-		clientCode, msg = info.Code, info.Msg
-	} else {
-		clientCode = unknownError
-		msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body))
+	if ie.Error != "" {
+		base.String = fmt.Sprintf("error while calling the iid service: %s", ie.Error)
 	}
-	return internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg)
+
+	return base
 }
diff --git a/snippets/auth.go b/snippets/auth.go
index 259a533..229e0f9 100644
--- a/snippets/auth.go
+++ b/snippets/auth.go
@@ -23,9 +23,9 @@ import (
 	"net/http"
 	"time"
 
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/auth"
-	"firebase.google.com/go/auth/hash"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/auth"
+	"firebase.google.com/go/v4/auth/hash"
 	"google.golang.org/api/iterator"
 )
 
diff --git a/snippets/db.go b/snippets/db.go
index 9e4a143..51af750 100644
--- a/snippets/db.go
+++ b/snippets/db.go
@@ -20,8 +20,8 @@ import (
 	"fmt"
 	"log"
 
-	"firebase.google.com/go"
-	"firebase.google.com/go/db"
+	"firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/db"
 	"google.golang.org/api/option"
 )
 
diff --git a/snippets/init.go b/snippets/init.go
index aba34c5..3125a70 100644
--- a/snippets/init.go
+++ b/snippets/init.go
@@ -19,8 +19,8 @@ import (
 	"context"
 	"log"
 
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/auth"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/auth"
 	"google.golang.org/api/option"
 )
 
diff --git a/snippets/messaging.go b/snippets/messaging.go
index 8b19112..129502e 100644
--- a/snippets/messaging.go
+++ b/snippets/messaging.go
@@ -20,8 +20,8 @@ import (
 	"log"
 	"time"
 
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/messaging"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/messaging"
 )
 
 func sendToToken(app *firebase.App) {
diff --git a/snippets/storage.go b/snippets/storage.go
index a39538a..5ef1b1f 100644
--- a/snippets/storage.go
+++ b/snippets/storage.go
@@ -18,7 +18,7 @@ import (
 	"context"
 	"log"
 
-	firebase "firebase.google.com/go"
+	firebase "firebase.google.com/go/v4"
 	"google.golang.org/api/option"
 )
 
diff --git a/storage/storage.go b/storage/storage.go
index 3403bf4..25fc585 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -20,7 +20,7 @@ import (
 	"errors"
 
 	"cloud.google.com/go/storage"
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 // Client is the interface for the Firebase Storage service.


Test patch:
diff --git a/.github/scripts/run_all_tests.sh b/.github/scripts/run_all_tests.sh
index b52b283..c47961b 100755
--- a/.github/scripts/run_all_tests.sh
+++ b/.github/scripts/run_all_tests.sh
@@ -22,4 +22,4 @@ gpg --quiet --batch --yes --decrypt --passphrase="${FIREBASE_SERVICE_ACCT_KEY}"
 
 echo "${FIREBASE_API_KEY}" > testdata/integration_apikey.txt
 
-go test -v -race firebase.google.com/go/...
+go test -v -race ./...
diff --git a/auth/auth_test.go b/auth/auth_test.go
index f42df87..cecea7b 100644
--- a/auth/auth_test.go
+++ b/auth/auth_test.go
@@ -27,7 +27,7 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"golang.org/x/oauth2/google"
 	"google.golang.org/api/option"
 	"google.golang.org/api/transport"
@@ -390,6 +390,7 @@ func TestCustomTokenInvalidCredential(t *testing.T) {
 		t.Fatal(err)
 	}
 
+	s.signer.(*iamSigner).httpClient.RetryConfig = nil
 	token, err := s.CustomToken(ctx, "user1")
 	if token != "" || err == nil {
 		t.Errorf("CustomTokenWithClaims() = (%q, %v); want = (\"\", error)", token, err)
@@ -510,8 +511,9 @@ func TestVerifyIDTokenInvalidSignature(t *testing.T) {
 	parts := strings.Split(testIDToken, ".")
 	token := fmt.Sprintf("%s:%s:invalidsignature", parts[0], parts[1])
 
-	if ft, err := client.VerifyIDToken(context.Background(), token); ft != nil || err == nil {
-		t.Errorf("VerifyIDToken('invalid-signature') = (%v, %v); want = (nil, error)", ft, err)
+	ft, err := client.VerifyIDToken(context.Background(), token)
+	if ft != nil || !IsIDTokenInvalid(err) {
+		t.Errorf("VerifyIDToken('invalid-signature') = (%v, %v); want = (nil, IDTokenInvalid)", ft, err)
 	}
 }
 
@@ -606,9 +608,12 @@ func TestVerifyIDTokenError(t *testing.T) {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			_, err := client.VerifyIDToken(context.Background(), tc.token)
-			if err == nil || !strings.HasPrefix(err.Error(), tc.want) {
+			if !IsIDTokenInvalid(err) || !strings.HasPrefix(err.Error(), tc.want) {
 				t.Errorf("VerifyIDToken(%q) = %v; want = %q", tc.name, err, tc.want)
 			}
+			if tc.name == "ExpiredToken" && !IsIDTokenExpired(err) {
+				t.Errorf("VerifyIDToken(%q) = %v; want = IDTokenExpired", tc.name, err)
+			}
 		})
 	}
 }
@@ -637,8 +642,9 @@ func TestVerifyIDTokenInvalidAlgorithm(t *testing.T) {
 			idTokenVerifier: testIDTokenVerifier,
 		},
 	}
-	if _, err := client.VerifyIDToken(context.Background(), token); err == nil {
-		t.Errorf("VerifyIDToken(InvalidAlgorithm) = nil; want error")
+	_, err = client.VerifyIDToken(context.Background(), token)
+	if !IsIDTokenInvalid(err) {
+		t.Errorf("VerifyIDToken(InvalidAlgorithm) = nil; want = IDTokenInvalid")
 	}
 }
 
@@ -670,8 +676,8 @@ func TestCustomTokenVerification(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if _, err := client.VerifyIDToken(context.Background(), token); err == nil {
-		t.Error("VeridyIDToken() = nil; want error")
+	if _, err := client.VerifyIDToken(context.Background(), token); !IsIDTokenInvalid(err) {
+		t.Error("VeridyIDToken() = nil; want = IDTokenInvalid")
 	}
 }
 
@@ -686,8 +692,8 @@ func TestCertificateRequestError(t *testing.T) {
 			idTokenVerifier: tv,
 		},
 	}
-	if _, err := client.VerifyIDToken(context.Background(), testIDToken); err == nil {
-		t.Error("VeridyIDToken() = nil; want error")
+	if _, err := client.VerifyIDToken(context.Background(), testIDToken); !IsCertificateFetchFailed(err) {
+		t.Error("VeridyIDToken() = nil; want = CertificateFetchFailed")
 	}
 }
 
@@ -732,8 +738,8 @@ func TestInvalidTokenDoesNotCheckRevoked(t *testing.T) {
 	s.Client.idTokenVerifier = testIDTokenVerifier
 
 	ft, err := s.Client.VerifyIDTokenAndCheckRevoked(context.Background(), "")
-	if ft != nil || err == nil {
-		t.Errorf("VerifyIDTokenAndCheckRevoked() = (%v, %v); want = (nil, error)", ft, err)
+	if ft != nil || !IsIDTokenInvalid(err) || IsIDTokenRevoked(err) {
+		t.Errorf("VerifyIDTokenAndCheckRevoked() = (%v, %v); want = (nil, IDTokenInvalid)", ft, err)
 	}
 	if len(s.Req) != 0 {
 		t.Errorf("Revocation checks = %d; want = 0", len(s.Req))
@@ -748,7 +754,7 @@ func TestVerifyIDTokenAndCheckRevokedError(t *testing.T) {
 
 	p, err := s.Client.VerifyIDTokenAndCheckRevoked(context.Background(), revokedToken)
 	we := "ID token has been revoked"
-	if p != nil || err == nil || err.Error() != we || !IsIDTokenRevoked(err) {
+	if p != nil || !IsIDTokenRevoked(err) || !IsIDTokenInvalid(err) || err.Error() != we {
 		t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
 			p, err, nil, we)
 	}
@@ -765,8 +771,8 @@ func TestIDTokenRevocationCheckUserMgtError(t *testing.T) {
 	s.Client.idTokenVerifier = testIDTokenVerifier
 
 	p, err := s.Client.VerifyIDTokenAndCheckRevoked(context.Background(), revokedToken)
-	if p != nil || err == nil || !IsUserNotFound(err) {
-		t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, user-not-found)", p, err, nil)
+	if p != nil || !IsUserNotFound(err) {
+		t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, UserNotFound)", p, err, nil)
 	}
 }
 
@@ -917,9 +923,12 @@ func TestVerifySessionCookieError(t *testing.T) {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			_, err := client.VerifySessionCookie(context.Background(), tc.token)
-			if err == nil || !strings.HasPrefix(err.Error(), tc.want) {
+			if !IsSessionCookieInvalid(err) || !strings.HasPrefix(err.Error(), tc.want) {
 				t.Errorf("VerifySessionCookie(%q) = %v; want = %q", tc.name, err, tc.want)
 			}
+			if tc.name == "ExpiredToken" && !IsSessionCookieExpired(err) {
+				t.Errorf("VerifySessionCookie(%q) = %v; want = SessionCookieExpired", tc.name, err)
+			}
 		})
 	}
 }
@@ -965,8 +974,8 @@ func TestInvalidCookieDoesNotCheckRevoked(t *testing.T) {
 	s.Client.cookieVerifier = testCookieVerifier
 
 	ft, err := s.Client.VerifySessionCookieAndCheckRevoked(context.Background(), "")
-	if ft != nil || err == nil {
-		t.Errorf("VerifySessionCookieAndCheckRevoked() = (%v, %v); want = (nil, error)", ft, err)
+	if ft != nil || !IsSessionCookieInvalid(err) {
+		t.Errorf("VerifySessionCookieAndCheckRevoked() = (%v, %v); want = (nil, SessionCookieInvalid)", ft, err)
 	}
 	if len(s.Req) != 0 {
 		t.Errorf("Revocation checks = %d; want = 0", len(s.Req))
@@ -981,7 +990,7 @@ func TestVerifySessionCookieAndCheckRevokedError(t *testing.T) {
 
 	p, err := s.Client.VerifySessionCookieAndCheckRevoked(context.Background(), revokedCookie)
 	we := "session cookie has been revoked"
-	if p != nil || err == nil || err.Error() != we || !IsSessionCookieRevoked(err) {
+	if p != nil || !IsSessionCookieRevoked(err) || !IsSessionCookieInvalid(err) || err.Error() != we {
 		t.Errorf("VerifySessionCookieAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)",
 			p, err, nil, we)
 	}
@@ -998,8 +1007,8 @@ func TestCookieRevocationCheckUserMgtError(t *testing.T) {
 	s.Client.cookieVerifier = testCookieVerifier
 
 	p, err := s.Client.VerifySessionCookieAndCheckRevoked(context.Background(), revokedCookie)
-	if p != nil || err == nil || !IsUserNotFound(err) {
-		t.Errorf("VerifySessionCookieAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, user-not-found)", p, err, nil)
+	if p != nil || !IsUserNotFound(err) {
+		t.Errorf("VerifySessionCookieAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, UserNotFound)", p, err, nil)
 	}
 }
 
@@ -1125,6 +1134,12 @@ func checkIDTokenVerifier(tv *tokenVerifier, projectID string) error {
 	if tv.shortName != "ID token" {
 		return fmt.Errorf("shortName = %q; want = %q", tv.shortName, "ID token")
 	}
+	if tv.invalidTokenCode != idTokenInvalid {
+		return fmt.Errorf("invalidTokenCode = %q; want = %q", tv.invalidTokenCode, idTokenInvalid)
+	}
+	if tv.expiredTokenCode != idTokenExpired {
+		return fmt.Errorf("expiredTokenCode = %q; want = %q", tv.expiredTokenCode, idTokenExpired)
+	}
 	return nil
 }
 
@@ -1138,6 +1153,12 @@ func checkCookieVerifier(tv *tokenVerifier, projectID string) error {
 	if tv.shortName != "session cookie" {
 		return fmt.Errorf("shortName = %q; want = %q", tv.shortName, "session cookie")
 	}
+	if tv.invalidTokenCode != sessionCookieInvalid {
+		return fmt.Errorf("invalidTokenCode = %q; want = %q", tv.invalidTokenCode, sessionCookieInvalid)
+	}
+	if tv.expiredTokenCode != sessionCookieExpired {
+		return fmt.Errorf("expiredTokenCode = %q; want = %q", tv.expiredTokenCode, sessionCookieExpired)
+	}
 	return nil
 }
 
diff --git a/auth/hash/hash_test.go b/auth/hash/hash_test.go
index 6fe47bc..7817eb4 100644
--- a/auth/hash/hash_test.go
+++ b/auth/hash/hash_test.go
@@ -19,8 +19,8 @@ import (
 	"reflect"
 	"testing"
 
-	"firebase.google.com/go/auth"
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/auth"
+	"firebase.google.com/go/v4/internal"
 )
 
 var (
diff --git a/auth/provider_config_test.go b/auth/provider_config_test.go
index 0bde823..7884375 100644
--- a/auth/provider_config_test.go
+++ b/auth/provider_config_test.go
@@ -24,6 +24,7 @@ import (
 	"strings"
 	"testing"
 
+	"firebase.google.com/go/v4/errorutils"
 	"google.golang.org/api/iterator"
 )
 
@@ -247,8 +248,8 @@ func TestCreateOIDCProviderConfigError(t *testing.T) {
 		ClientID(oidcProviderConfig.ClientID).
 		Issuer(oidcProviderConfig.Issuer)
 	oidc, err := client.CreateOIDCProviderConfig(context.Background(), options)
-	if oidc != nil || !IsUnknown(err) {
-		t.Errorf("CreateOIDCProviderConfig() = (%v, %v); want = (nil, %q)", oidc, err, "unknown-error")
+	if oidc != nil || !errorutils.IsInternal(err) {
+		t.Errorf("CreateOIDCProviderConfig() = (%v, %v); want = (nil, %q)", oidc, err, "internal-error")
 	}
 }
 
@@ -583,8 +584,8 @@ func TestOIDCProviderConfigsError(t *testing.T) {
 	client.baseClient.httpClient.RetryConfig = nil
 	it := client.OIDCProviderConfigs(context.Background(), "")
 	config, err := it.Next()
-	if config != nil || err == nil || !IsUnknown(err) {
-		t.Errorf("OIDCProviderConfigs() = (%v, %v); want = (nil, %q)", config, err, "unknown-error")
+	if config != nil || err == nil || !errorutils.IsInternal(err) {
+		t.Errorf("OIDCProviderConfigs() = (%v, %v); want = (nil, %q)", config, err, "internal-error")
 	}
 }
 
@@ -775,8 +776,8 @@ func TestCreateSAMLProviderConfigError(t *testing.T) {
 		RPEntityID(samlProviderConfig.RPEntityID).
 		CallbackURL(samlProviderConfig.CallbackURL)
 	saml, err := client.CreateSAMLProviderConfig(context.Background(), options)
-	if saml != nil || !IsUnknown(err) {
-		t.Errorf("CreateSAMLProviderConfig() = (%v, %v); want = (nil, %q)", saml, err, "unknown-error")
+	if saml != nil || !errorutils.IsInternal(err) {
+		t.Errorf("CreateSAMLProviderConfig() = (%v, %v); want = (nil, %q)", saml, err, "internal-error")
 	}
 }
 
@@ -1211,8 +1212,8 @@ func TestSAMLProviderConfigsError(t *testing.T) {
 	client.baseClient.httpClient.RetryConfig = nil
 	it := client.SAMLProviderConfigs(context.Background(), "")
 	config, err := it.Next()
-	if config != nil || err == nil || !IsUnknown(err) {
-		t.Errorf("SAMLProviderConfigs() = (%v, %v); want = (nil, %q)", config, err, "unknown-error")
+	if config != nil || err == nil || !errorutils.IsInternal(err) {
+		t.Errorf("SAMLProviderConfigs() = (%v, %v); want = (nil, %q)", config, err, "internal-error")
 	}
 }
 
diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go
index 3a4e35f..c5d6b34 100644
--- a/auth/tenant_mgt_test.go
+++ b/auth/tenant_mgt_test.go
@@ -27,6 +27,7 @@ import (
 	"testing"
 	"time"
 
+	"firebase.google.com/go/v4/errorutils"
 	"google.golang.org/api/iterator"
 )
 
@@ -1252,8 +1253,8 @@ func TestCreateTenantError(t *testing.T) {
 	client := s.Client
 	client.TenantManager.httpClient.RetryConfig = nil
 	tenant, err := client.TenantManager.CreateTenant(context.Background(), &TenantToCreate{})
-	if tenant != nil || !IsUnknown(err) {
-		t.Errorf("CreateTenant() = (%v, %v); want = (nil, %q)", tenant, err, "unknown-error")
+	if tenant != nil || !errorutils.IsInternal(err) {
+		t.Errorf("CreateTenant() = (%v, %v); want = (nil, %q)", tenant, err, "internal-error")
 	}
 }
 
@@ -1356,8 +1357,8 @@ func TestUpdateTenantError(t *testing.T) {
 	client.TenantManager.httpClient.RetryConfig = nil
 	options := (&TenantToUpdate{}).DisplayName("")
 	tenant, err := client.TenantManager.UpdateTenant(context.Background(), "tenantID", options)
-	if tenant != nil || !IsUnknown(err) {
-		t.Errorf("UpdateTenant() = (%v, %v); want = (nil, %q)", tenant, err, "unknown-error")
+	if tenant != nil || !errorutils.IsInternal(err) {
+		t.Errorf("UpdateTenant() = (%v, %v); want = (nil, %q)", tenant, err, "internal-error")
 	}
 }
 
@@ -1502,8 +1503,8 @@ func TestTenantsError(t *testing.T) {
 	client.TenantManager.httpClient.RetryConfig = nil
 	it := client.TenantManager.Tenants(context.Background(), "")
 	config, err := it.Next()
-	if config != nil || err == nil || !IsUnknown(err) {
-		t.Errorf("Tenants() = (%v, %v); want = (nil, %q)", config, err, "unknown-error")
+	if config != nil || !errorutils.IsInternal(err) {
+		t.Errorf("Tenants() = (%v, %v); want = (nil, %q)", config, err, "internal-error")
 	}
 }
 
diff --git a/auth/token_generator_test.go b/auth/token_generator_test.go
index 5a408f1..fee005a 100644
--- a/auth/token_generator_test.go
+++ b/auth/token_generator_test.go
@@ -26,7 +26,8 @@ import (
 	"strings"
 	"testing"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/internal"
 )
 
 func TestEncodeToken(t *testing.T) {
@@ -161,9 +162,9 @@ func TestIAMSignerHTTPError(t *testing.T) {
 	defer server.Close()
 	signer.iamHost = server.URL
 
-	want := "http error status: 403; reason: test reason"
+	want := "test reason"
 	_, err = signer.Sign(context.Background(), []byte("input"))
-	if err == nil || !IsInsufficientPermission(err) || err.Error() != want {
+	if err == nil || !errorutils.IsPermissionDenied(err) || err.Error() != want {
 		t.Errorf("Sign() = %v; want = %q", err, want)
 	}
 }
@@ -188,9 +189,9 @@ func TestIAMSignerUnknownHTTPError(t *testing.T) {
 	defer server.Close()
 	signer.iamHost = server.URL
 
-	want := "http error status: 403; reason: client encountered an unknown error; response: not json"
+	want := "unexpected http response with status: 403\nnot json"
 	_, err = signer.Sign(context.Background(), []byte("input"))
-	if err == nil || !IsUnknown(err) || err.Error() != want {
+	if err == nil || !errorutils.IsPermissionDenied(err) || err.Error() != want {
 		t.Errorf("Sign() = %v; want = %q", err, want)
 	}
 }
@@ -251,11 +252,15 @@ func TestIAMSignerNoMetadataService(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if _, err = signer.Email(ctx); err == nil {
-		t.Errorf("Email() = nil; want = error")
+	want := "failed to determine service account: "
+	_, err = signer.Email(ctx)
+	if err == nil || !strings.HasPrefix(err.Error(), want) {
+		t.Errorf("Email() = %v; want = %q", err, want)
 	}
-	if _, err = signer.Sign(ctx, []byte("input")); err == nil {
-		t.Errorf("Sign() = nil; want = error")
+
+	_, err = signer.Sign(ctx, []byte("input"))
+	if err == nil || !strings.HasPrefix(err.Error(), want) {
+		t.Errorf("Sign() = %v; want = %q", err, want)
 	}
 }
 
diff --git a/auth/token_verifier_test.go b/auth/token_verifier_test.go
index 9d9edd1..f226fd3 100644
--- a/auth/token_verifier_test.go
+++ b/auth/token_verifier_test.go
@@ -24,7 +24,7 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 )
 
 func TestNewIDTokenVerifier(t *testing.T) {
diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go
index 0ee5c67..e8393a7 100644
--- a/auth/user_mgt_test.go
+++ b/auth/user_mgt_test.go
@@ -30,7 +30,8 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/iterator"
 )
 
@@ -396,19 +397,19 @@ func TestGetNonExistingUser(t *testing.T) {
 	s := echoServer([]byte(resp), t)
 	defer s.Close()
 
-	we := `cannot find user from uid: "id-nonexisting"`
+	we := `no user exists with the uid: "id-nonexisting"`
 	user, err := s.Client.GetUser(context.Background(), "id-nonexisting")
 	if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) {
 		t.Errorf("GetUser(non-existing) = (%v, %q); want = (nil, %q)", user, err, we)
 	}
 
-	we = `cannot find user from email: "foo@bar.nonexisting"`
+	we = `no user exists with the email: "foo@bar.nonexisting"`
 	user, err = s.Client.GetUserByEmail(context.Background(), "foo@bar.nonexisting")
 	if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) {
 		t.Errorf("GetUserByEmail(non-existing) = (%v, %q); want = (nil, %q)", user, err, we)
 	}
 
-	we = `cannot find user from phone number: "+12345678901"`
+	we = `no user exists with the phone number: "+12345678901"`
 	user, err = s.Client.GetUserByPhoneNumber(context.Background(), "+12345678901")
 	if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) {
 		t.Errorf("GetUserPhoneNumber(non-existing) = (%v, %q); want = (nil, %q)", user, err, we)
@@ -1542,8 +1543,8 @@ func TestSessionCookieError(t *testing.T) {
 		t.Fatalf("SessionCookie() = (%q, %v); want = (%q, error)", cookie, err, "")
 	}
 
-	want := fmt.Sprintf("http error status: 403; body: %s", resp)
-	if err.Error() != want || !IsInsufficientPermission(err) {
+	want := fmt.Sprintf("unexpected http response with status: 403\n%s", resp)
+	if err.Error() != want || !errorutils.IsPermissionDenied(err) {
 		t.Errorf("SessionCookie() error = %v; want = %q", err, want)
 	}
 }
@@ -1599,37 +1600,144 @@ func TestHTTPError(t *testing.T) {
 		t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err)
 	}
 
-	want := `http error status: 500; body: {"error":"test"}`
-	if err.Error() != want || !IsUnknown(err) {
+	want := "unexpected http response with status: 500\n{\"error\":\"test\"}"
+	if err.Error() != want || !errorutils.IsInternal(err) {
 		t.Errorf("GetUser() = %v; want = %q", err, want)
 	}
 }
 
 func TestHTTPErrorWithCode(t *testing.T) {
-	errorCodes := map[string]func(error) bool{
-		"CONFIGURATION_NOT_FOUND": IsConfigurationNotFound,
-		"DUPLICATE_EMAIL":         IsEmailAlreadyExists,
-		"DUPLICATE_LOCAL_ID":      IsUIDAlreadyExists,
-		"EMAIL_EXISTS":            IsEmailAlreadyExists,
-		"INSUFFICIENT_PERMISSION": IsInsufficientPermission,
-		"INVALID_EMAIL":           IsInvalidEmail,
-		"PHONE_NUMBER_EXISTS":     IsPhoneNumberAlreadyExists,
-		"PROJECT_NOT_FOUND":       IsProjectNotFound,
+	errorCodes := map[string]struct {
+		authCheck     func(error) bool
+		platformCheck func(error) bool
+		want          string
+	}{
+		"CONFIGURATION_NOT_FOUND": {
+			IsConfigurationNotFound,
+			errorutils.IsNotFound,
+			"no IdP configuration corresponding to the provided identifier",
+		},
+		"DUPLICATE_EMAIL": {
+			IsEmailAlreadyExists,
+			errorutils.IsAlreadyExists,
+			"user with the provided email already exists",
+		},
+		"DUPLICATE_LOCAL_ID": {
+			IsUIDAlreadyExists,
+			errorutils.IsAlreadyExists,
+			"user with the provided uid already exists",
+		},
+		"EMAIL_EXISTS": {
+			IsEmailAlreadyExists,
+			errorutils.IsAlreadyExists,
+			"user with the provided email already exists",
+		},
+		"INVALID_DYNAMIC_LINK_DOMAIN": {
+			IsInvalidDynamicLinkDomain,
+			errorutils.IsInvalidArgument,
+			"the provided dynamic link domain is not configured or authorized for the current project",
+		},
+		"PHONE_NUMBER_EXISTS": {
+			IsPhoneNumberAlreadyExists,
+			errorutils.IsAlreadyExists,
+			"user with the provided phone number already exists",
+		},
+		"UNAUTHORIZED_DOMAIN": {
+			IsUnauthorizedContinueURI,
+			errorutils.IsInvalidArgument,
+			"domain of the continue url is not whitelisted",
+		},
+		"USER_NOT_FOUND": {
+			IsUserNotFound,
+			errorutils.IsNotFound,
+			"no user record found for the given identifier",
+		},
+	}
+	s := echoServer(nil, t)
+	defer s.Close()
+	s.Client.baseClient.httpClient.RetryConfig = nil
+	s.Status = http.StatusInternalServerError
+
+	for code, conf := range errorCodes {
+		s.Resp = []byte(fmt.Sprintf(`{"error":{"message":"%s"}}`, code))
+		u, err := s.Client.GetUser(context.Background(), "some uid")
+		if u != nil || err == nil {
+			t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err)
+		}
+
+		if err.Error() != conf.want || !conf.authCheck(err) || !conf.platformCheck(err) {
+			t.Errorf("GetUser() = %v; want = %q", err, conf.want)
+		}
+	}
+}
+
+func TestAuthErrorWithCodeAndDetails(t *testing.T) {
+	resp := []byte(`{"error":{"message":"USER_NOT_FOUND: extra details"}}`)
+	s := echoServer(resp, t)
+	defer s.Close()
+	s.Client.baseClient.httpClient.RetryConfig = nil
+	s.Status = http.StatusInternalServerError
+
+	u, err := s.Client.GetUser(context.Background(), "some uid")
+	if u != nil || err == nil {
+		t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err)
+	}
+
+	want := "no user record found for the given identifier: extra details"
+	if err.Error() != want || !IsUserNotFound(err) || !errorutils.IsNotFound(err) {
+		t.Errorf("GetUser() = %v; want = %q", err, want)
+	}
+}
+
+func TestAuthErrorWithUnknownCode(t *testing.T) {
+	resp := `{"error":{"message":"UNKNOWN_CODE: extra details"}}`
+	s := echoServer([]byte(resp), t)
+	defer s.Close()
+	s.Client.baseClient.httpClient.RetryConfig = nil
+	s.Status = http.StatusInternalServerError
+
+	u, err := s.Client.GetUser(context.Background(), "some uid")
+	if u != nil || err == nil {
+		t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err)
+	}
+
+	want := fmt.Sprintf("unexpected http response with status: 500\n%s", resp)
+	if err.Error() != want || !errorutils.IsInternal(err) {
+		t.Errorf("GetUser() = %v; want = %q", err, want)
+	}
+}
+
+func TestUnmappedHTTPError(t *testing.T) {
+	errorCodes := map[string]struct {
+		authCheck func(error) bool
+	}{
+		"PROJECT_NOT_FOUND": {
+			IsProjectNotFound,
+		},
+		"INVALID_EMAIL": {
+			IsInvalidEmail,
+		},
+		"INSUFFICIENT_PERMISSION": {
+			IsInsufficientPermission,
+		},
+		"UNKNOWN": {
+			IsUnknown,
+		},
 	}
 	s := echoServer(nil, t)
 	defer s.Close()
 	s.Client.baseClient.httpClient.RetryConfig = nil
 	s.Status = http.StatusInternalServerError
 
-	for code, check := range errorCodes {
+	for code, conf := range errorCodes {
 		s.Resp = []byte(fmt.Sprintf(`{"error":{"message":"%s"}}`, code))
 		u, err := s.Client.GetUser(context.Background(), "some uid")
 		if u != nil || err == nil {
 			t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err)
 		}
 
-		want := fmt.Sprintf(`http error status: 500; body: {"error":{"message":"%s"}}`, code)
-		if err.Error() != want || !check(err) {
+		want := fmt.Sprintf("unexpected http response with status: 500\n%s", string(s.Resp))
+		if err.Error() != want || conf.authCheck(err) || !errorutils.IsInternal(err) {
 			t.Errorf("GetUser() = %v; want = %q", err, want)
 		}
 	}
diff --git a/db/db_test.go b/db/db_test.go
index 3c32a01..1ec624f 100644
--- a/db/db_test.go
+++ b/db/db_test.go
@@ -28,7 +28,7 @@ import (
 	"runtime"
 	"testing"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 )
 
diff --git a/db/query_test.go b/db/query_test.go
index c20ad7f..c540bd1 100644
--- a/db/query_test.go
+++ b/db/query_test.go
@@ -16,8 +16,11 @@ package db
 import (
 	"context"
 	"fmt"
+	"net/http"
 	"reflect"
 	"testing"
+
+	"firebase.google.com/go/v4/errorutils"
 )
 
 var sortableKeysResp = map[string]interface{}{
@@ -768,10 +771,15 @@ func TestQueryHttpError(t *testing.T) {
 
 	want := "http error status: 500; reason: test error"
 	result, err := testref.OrderByChild("child").GetOrdered(context.Background())
-	if err == nil || err.Error() != want {
-		t.Errorf("GetOrdered() = %v; want = %v", err, want)
+	if result != nil || err == nil || err.Error() != want {
+		t.Fatalf("GetOrdered() = (%v, %v); want = (nil, %v)", result, err, want)
 	}
-	if result != nil {
-		t.Errorf("GetOrdered() = %v; want = nil", result)
+	if !errorutils.IsInternal(err) {
+		t.Errorf("IsInternal(err) = false; want = true")
+	}
+
+	resp := errorutils.HTTPResponse(err)
+	if resp == nil || resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("HTTPResponse(err) = %v; want = {StatusCode: %d}", resp, http.StatusInternalServerError)
 	}
 }
diff --git a/db/ref_test.go b/db/ref_test.go
index be832b1..5695690 100644
--- a/db/ref_test.go
+++ b/db/ref_test.go
@@ -20,6 +20,8 @@ import (
 	"net/http"
 	"reflect"
 	"testing"
+
+	"firebase.google.com/go/v4/errorutils"
 )
 
 type refOp func(r *Ref) error
@@ -289,6 +291,15 @@ func TestWelformedHttpError(t *testing.T) {
 			if err == nil || err.Error() != want {
 				t.Errorf("%s = %v; want = %v", tc.name, err, want)
 			}
+
+			if !errorutils.IsInternal(err) {
+				t.Errorf("IsInternal(err) = false; want = true")
+			}
+
+			resp := errorutils.HTTPResponse(err)
+			if resp == nil || resp.StatusCode != http.StatusInternalServerError {
+				t.Errorf("HTTPResponse(err) = %v; want = {StatusCode: %d}", resp, http.StatusInternalServerError)
+			}
 		})
 	}
 
@@ -303,13 +314,22 @@ func TestUnexpectedHttpError(t *testing.T) {
 	srv := mock.Start(client)
 	defer srv.Close()
 
-	want := "http error status: 500; reason: \"unexpected error\""
+	want := "unexpected http response with status: 500\n\"unexpected error\""
 	for _, tc := range testOps {
 		t.Run(tc.name, func(t *testing.T) {
 			err := tc.op(testref)
 			if err == nil || err.Error() != want {
 				t.Errorf("%s = %v; want = %v", tc.name, err, want)
 			}
+
+			if !errorutils.IsInternal(err) {
+				t.Errorf("IsInternal(err) = false; want = true")
+			}
+
+			resp := errorutils.HTTPResponse(err)
+			if resp == nil || resp.StatusCode != http.StatusInternalServerError {
+				t.Errorf("HTTPResponse(err) = %v; want = {StatusCode: %d}", resp, http.StatusInternalServerError)
+			}
 		})
 	}
 
@@ -319,6 +339,59 @@ func TestUnexpectedHttpError(t *testing.T) {
 	}
 }
 
+func TestPlatformErrorCodes(t *testing.T) {
+	mock := &mockServer{Resp: map[string]string{"error": "test error"}}
+	srv := mock.Start(client)
+	defer srv.Close()
+
+	cases := []struct {
+		name   string
+		status int
+		check  func(err error) bool
+	}{
+		{
+			name:   "InvalidArgument",
+			status: http.StatusBadRequest,
+			check:  errorutils.IsInvalidArgument,
+		},
+		{
+			name:   "Unauthenticated",
+			status: http.StatusUnauthorized,
+			check:  errorutils.IsUnauthenticated,
+		},
+		{
+			name:   "NotFound",
+			status: http.StatusNotFound,
+			check:  errorutils.IsNotFound,
+		},
+		{
+			name:   "Internal",
+			status: http.StatusInternalServerError,
+			check:  errorutils.IsInternal,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			mock.Status = tc.status
+			want := fmt.Sprintf("http error status: %d; reason: test error", tc.status)
+			err := testref.Delete(context.Background())
+			if err == nil || err.Error() != want {
+				t.Errorf("Delete() = %v; want = %v", err, want)
+			}
+
+			if !tc.check(err) {
+				t.Errorf("Is%s(err) = false; want = true", tc.name)
+			}
+
+			resp := errorutils.HTTPResponse(err)
+			if resp == nil || resp.StatusCode != tc.status {
+				t.Errorf("HTTPResponse(err) = %v; want = {StatusCode: %d}", resp, tc.status)
+			}
+		})
+	}
+}
+
 func TestInvalidPath(t *testing.T) {
 	mock := &mockServer{Resp: "test"}
 	srv := mock.Start(client)
@@ -719,6 +792,42 @@ func TestTransactionAbort(t *testing.T) {
 	checkAllRequests(t, mock.Reqs, wanted)
 }
 
+func TestTransactionFailure(t *testing.T) {
+	mock := &mockServer{
+		Resp:   &person{"Peter Parker", 17},
+		Header: map[string]string{"ETag": "mock-etag1"},
+	}
+	srv := mock.Start(client)
+	defer srv.Close()
+
+	cnt := 0
+	var fn UpdateFn = func(t TransactionNode) (interface{}, error) {
+		if cnt == 0 {
+			mock.Status = http.StatusInternalServerError
+			mock.Resp = map[string]string{"error": "test error"}
+		}
+
+		cnt++
+		var p person
+		if err := t.Unmarshal(&p); err != nil {
+			return nil, err
+		}
+
+		p.Age++
+		return &p, nil
+	}
+
+	want := "http error status: 500; reason: test error"
+	err := testref.Transaction(context.Background(), fn)
+	if err == nil || err.Error() != want {
+		t.Errorf("Transaction() = %v; want = %v", err, want)
+	}
+
+	if !errorutils.IsInternal(err) {
+		t.Errorf("IsInternal() = false; want = true")
+	}
+}
+
 func TestDelete(t *testing.T) {
 	mock := &mockServer{Resp: "null"}
 	srv := mock.Start(client)
diff --git a/firebase_test.go b/firebase_test.go
index f29bb73..7830225 100644
--- a/firebase_test.go
+++ b/firebase_test.go
@@ -29,7 +29,7 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/messaging"
+	"firebase.google.com/go/v4/messaging"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google"
 	"google.golang.org/api/option"
@@ -376,8 +376,7 @@ func TestMessagingSendWithCustomEndpoint(t *testing.T) {
 	tokenSource := &testTokenSource{AccessToken: "mock-token-from-custom"}
 	app, err := NewApp(
 		ctx,
-		nil,
-		option.WithCredentialsFile("testdata/service_account.json"),
+		&Config{ProjectID: "test-project-id"},
 		option.WithTokenSource(tokenSource),
 		option.WithEndpoint(ts.URL),
 	)
@@ -391,7 +390,7 @@ func TestMessagingSendWithCustomEndpoint(t *testing.T) {
 	}
 
 	msg := &messaging.Message{
-		Token: "...",
+		Token: "token",
 	}
 	n, err := c.Send(ctx, msg)
 	if n != name || err != nil {
diff --git a/iid/iid_test.go b/iid/iid_test.go
index 3b785c1..c913420 100644
--- a/iid/iid_test.go
+++ b/iid/iid_test.go
@@ -21,7 +21,8 @@ import (
 	"net/http/httptest"
 	"testing"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 )
 
@@ -104,6 +105,17 @@ func TestDeleteInstanceIDError(t *testing.T) {
 	client.client.RetryConfig = nil
 
 	errorHandlers := map[int]func(error) bool{
+		http.StatusBadRequest:          errorutils.IsInvalidArgument,
+		http.StatusUnauthorized:        errorutils.IsUnauthenticated,
+		http.StatusForbidden:           errorutils.IsPermissionDenied,
+		http.StatusNotFound:            errorutils.IsNotFound,
+		http.StatusConflict:            errorutils.IsConflict,
+		http.StatusTooManyRequests:     errorutils.IsResourceExhausted,
+		http.StatusInternalServerError: errorutils.IsInternal,
+		http.StatusServiceUnavailable:  errorutils.IsUnavailable,
+	}
+
+	deprecatedErrorHandlers := map[int]func(error) bool{
 		http.StatusBadRequest:          IsInvalidArgument,
 		http.StatusUnauthorized:        IsInsufficientPermission,
 		http.StatusForbidden:           IsInsufficientPermission,
@@ -116,12 +128,22 @@ func TestDeleteInstanceIDError(t *testing.T) {
 
 	for code, check := range errorHandlers {
 		status = code
-		want := fmt.Sprintf("instance id %q: %s", "test-iid", errorCodes[code].message)
+		want := fmt.Sprintf("instance id %q: %s", "test-iid", errorMessages[code])
 		err := client.DeleteInstanceID(ctx, "test-iid")
 		if err == nil || !check(err) || err.Error() != want {
 			t.Errorf("DeleteInstanceID() = %v; want = %v", err, want)
 		}
 
+		resp := errorutils.HTTPResponse(err)
+		if resp.StatusCode != code {
+			t.Errorf("HTTPResponse().StatusCode = %d; want = %d", resp.StatusCode, code)
+		}
+
+		deprecatedCheck := deprecatedErrorHandlers[code]
+		if !deprecatedCheck(err) {
+			t.Errorf("DeleteInstanceID() = %v; want = %v", err, want)
+		}
+
 		if tr == nil {
 			t.Fatalf("Request = nil; want non-nil")
 		}
@@ -155,11 +177,17 @@ func TestDeleteInstanceIDUnexpectedError(t *testing.T) {
 	}
 	client.endpoint = ts.URL
 
-	want := "http error status: 511; reason: {}"
+	want := "unexpected http response with status: 511\n{}"
 	err = client.DeleteInstanceID(ctx, "test-iid")
-	if err == nil || !IsUnknown(err) || err.Error() != want {
+	if err == nil || err.Error() != want {
 		t.Errorf("DeleteInstanceID() = %v; want = %v", err, want)
 	}
+	if !IsUnknown(err) {
+		t.Errorf("IsUnknown() = false; want = true")
+	}
+	if !errorutils.IsUnknown(err) {
+		t.Errorf("errorutils.IsUnknown() = false; want = true")
+	}
 
 	if tr == nil {
 		t.Fatalf("Request = nil; want non-nil")
@@ -190,7 +218,6 @@ func TestDeleteInstanceIDConnectionError(t *testing.T) {
 	client.client.RetryConfig = nil
 
 	if err := client.DeleteInstanceID(ctx, "test-iid"); err == nil {
-		t.Errorf("DeleteInstanceID() = nil; want = error")
-		return
+		t.Fatalf("DeleteInstanceID() = nil; want = error")
 	}
 }
diff --git a/integration/auth/auth_test.go b/integration/auth/auth_test.go
index a9fa7b3..668b45b 100644
--- a/integration/auth/auth_test.go
+++ b/integration/auth/auth_test.go
@@ -29,9 +29,9 @@ import (
 	"testing"
 	"time"
 
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/auth"
-	"firebase.google.com/go/integration/internal"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/auth"
+	"firebase.google.com/go/v4/integration/internal"
 	"golang.org/x/oauth2/google"
 	"google.golang.org/api/option"
 )
diff --git a/integration/auth/provider_config_test.go b/integration/auth/provider_config_test.go
index ae7ad14..4d830de 100644
--- a/integration/auth/provider_config_test.go
+++ b/integration/auth/provider_config_test.go
@@ -21,7 +21,7 @@ import (
 	"reflect"
 	"testing"
 
-	"firebase.google.com/go/auth"
+	"firebase.google.com/go/v4/auth"
 	"google.golang.org/api/iterator"
 )
 
diff --git a/integration/auth/tenant_mgt_test.go b/integration/auth/tenant_mgt_test.go
index e803443..381bb61 100644
--- a/integration/auth/tenant_mgt_test.go
+++ b/integration/auth/tenant_mgt_test.go
@@ -22,7 +22,7 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/auth"
+	"firebase.google.com/go/v4/auth"
 	"google.golang.org/api/iterator"
 )
 
diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go
index 63419a8..9e64559 100644
--- a/integration/auth/user_mgt_test.go
+++ b/integration/auth/user_mgt_test.go
@@ -28,8 +28,8 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/auth"
-	"firebase.google.com/go/auth/hash"
+	"firebase.google.com/go/v4/auth"
+	"firebase.google.com/go/v4/auth/hash"
 	"google.golang.org/api/iterator"
 )
 
diff --git a/integration/db/db_test.go b/integration/db/db_test.go
index d3b3197..fc34b5f 100644
--- a/integration/db/db_test.go
+++ b/integration/db/db_test.go
@@ -28,9 +28,10 @@ import (
 	"reflect"
 	"testing"
 
-	"firebase.google.com/go"
-	"firebase.google.com/go/db"
-	"firebase.google.com/go/integration/internal"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/db"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/integration/internal"
 )
 
 var client *db.Client
@@ -584,13 +585,24 @@ func TestNoAccess(t *testing.T) {
 	var got string
 	if err := r.Get(context.Background(), &got); err == nil || got != "" {
 		t.Errorf("Get() = (%q, %v); want = (empty, error)", got, err)
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
+
 	if err := r.Set(context.Background(), "update"); err == nil {
 		t.Errorf("Set() = nil; want = error")
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
 }
 
@@ -600,11 +612,17 @@ func TestReadAccess(t *testing.T) {
 	if err := r.Get(context.Background(), &got); err != nil || got != "test" {
 		t.Errorf("Get() = (%q, %v); want = (%q, nil)", got, err, "test")
 	}
-	if err := r.Set(context.Background(), "update"); err == nil {
-		t.Errorf("Set() = nil; want = error")
-	} else if err.Error() != permDenied {
+
+	err := r.Set(context.Background(), "update")
+	if err == nil {
+		t.Fatalf("Set() = nil; want = error")
+	}
+	if err.Error() != permDenied {
 		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
 	}
+	if !errorutils.IsUnauthenticated(err) {
+		t.Errorf("IsUnauthenticated() = false; want = true")
+	}
 }
 
 func TestReadWriteAccess(t *testing.T) {
@@ -621,11 +639,16 @@ func TestReadWriteAccess(t *testing.T) {
 func TestQueryAccess(t *testing.T) {
 	r := aoClient.NewRef("_adminsdk/go/protected")
 	got := make(map[string]interface{})
-	if err := r.OrderByKey().LimitToFirst(2).Get(context.Background(), &got); err == nil {
-		t.Errorf("OrderByQuery() = nil; want = error")
-	} else if err.Error() != permDenied {
+	err := r.OrderByKey().LimitToFirst(2).Get(context.Background(), &got)
+	if err == nil {
+		t.Fatalf("OrderByQuery() = nil; want = error")
+	}
+	if err.Error() != permDenied {
 		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
 	}
+	if !errorutils.IsUnauthenticated(err) {
+		t.Errorf("IsUnauthenticated() = false; want = true")
+	}
 }
 
 func TestGuestAccess(t *testing.T) {
@@ -634,32 +657,53 @@ func TestGuestAccess(t *testing.T) {
 	if err := r.Get(context.Background(), &got); err != nil || got != "test" {
 		t.Errorf("Get() = (%q, %v); want = (%q, nil)", got, err, "test")
 	}
+
 	if err := r.Set(context.Background(), "update"); err == nil {
 		t.Errorf("Set() = nil; want = error")
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
 
 	got = ""
 	r = guestClient.NewRef("_adminsdk/go")
 	if err := r.Get(context.Background(), &got); err == nil || got != "" {
 		t.Errorf("Get() = (%q, %v); want = (empty, error)", got, err)
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
 
 	c := r.Child("protected/user2")
 	if err := c.Get(context.Background(), &got); err == nil || got != "" {
 		t.Errorf("Get() = (%q, %v); want = (empty, error)", got, err)
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
 
 	c = r.Child("admin")
 	if err := c.Get(context.Background(), &got); err == nil || got != "" {
 		t.Errorf("Get() = (%q, %v); want = (empty, error)", got, err)
-	} else if err.Error() != permDenied {
-		t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+	} else {
+		if err.Error() != permDenied {
+			t.Errorf("Error = %q; want = %q", err.Error(), permDenied)
+		}
+		if !errorutils.IsUnauthenticated(err) {
+			t.Errorf("IsUnauthenticated() = false; want = true")
+		}
 	}
 }
 
diff --git a/integration/db/query_test.go b/integration/db/query_test.go
index b473d4c..6bb48cf 100644
--- a/integration/db/query_test.go
+++ b/integration/db/query_test.go
@@ -19,7 +19,7 @@ import (
 	"reflect"
 	"testing"
 
-	"firebase.google.com/go/db"
+	"firebase.google.com/go/v4/db"
 )
 
 var heightSorted = []string{
diff --git a/integration/firestore/firestore_test.go b/integration/firestore/firestore_test.go
index 1b861d9..8e3dd60 100644
--- a/integration/firestore/firestore_test.go
+++ b/integration/firestore/firestore_test.go
@@ -20,7 +20,7 @@ import (
 	"reflect"
 	"testing"
 
-	"firebase.google.com/go/integration/internal"
+	"firebase.google.com/go/v4/integration/internal"
 )
 
 func TestFirestore(t *testing.T) {
diff --git a/integration/iid/iid_test.go b/integration/iid/iid_test.go
index edf4cac..a14b2fd 100644
--- a/integration/iid/iid_test.go
+++ b/integration/iid/iid_test.go
@@ -22,8 +22,9 @@ import (
 	"os"
 	"testing"
 
-	"firebase.google.com/go/iid"
-	"firebase.google.com/go/integration/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/iid"
+	"firebase.google.com/go/v4/integration/internal"
 )
 
 var client *iid.Client
@@ -57,7 +58,7 @@ func TestNonExisting(t *testing.T) {
 		t.Errorf("DeleteInstanceID(non-existing) = nil; want error")
 	}
 	want := `instance id "fictive-ID0": failed to find the instance id`
-	if !iid.IsNotFound(err) || err.Error() != want {
+	if !errorutils.IsNotFound(err) || err.Error() != want {
 		t.Errorf("DeleteInstanceID(non-existing) = %v; want = %v", err, want)
 	}
 }
diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go
index f62aa72..b86aa2a 100644
--- a/integration/messaging/messaging_test.go
+++ b/integration/messaging/messaging_test.go
@@ -24,8 +24,8 @@ import (
 	"regexp"
 	"testing"
 
-	"firebase.google.com/go/integration/internal"
-	"firebase.google.com/go/messaging"
+	"firebase.google.com/go/v4/integration/internal"
+	"firebase.google.com/go/v4/messaging"
 )
 
 // The registration token has the proper format, but is not valid (i.e. expired). The intention of
diff --git a/integration/storage/storage_test.go b/integration/storage/storage_test.go
index 6865af6..2c2706b 100644
--- a/integration/storage/storage_test.go
+++ b/integration/storage/storage_test.go
@@ -24,9 +24,9 @@ import (
 	"testing"
 
 	gcs "cloud.google.com/go/storage"
-	"firebase.google.com/go"
-	"firebase.google.com/go/integration/internal"
-	"firebase.google.com/go/storage"
+	"firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/integration/internal"
+	"firebase.google.com/go/v4/storage"
 )
 
 var ctx context.Context
diff --git a/internal/errors_test.go b/internal/errors_test.go
new file mode 100644
index 0000000..3733429
--- /dev/null
+++ b/internal/errors_test.go
@@ -0,0 +1,337 @@
+// Copyright 2020 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 internal
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"syscall"
+	"testing"
+)
+
+var platformErrorCodes = []ErrorCode{
+	InvalidArgument,
+	Unauthenticated,
+	NotFound,
+	Aborted,
+	AlreadyExists,
+	Internal,
+	Unavailable,
+	Unknown,
+}
+
+func TestPlatformError(t *testing.T) {
+	var body string
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+		w.Write([]byte(body))
+	})
+	server := httptest.NewServer(handler)
+	defer server.Close()
+
+	client := &HTTPClient{
+		Client: http.DefaultClient,
+	}
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    server.URL,
+	}
+	want := "Test error message"
+
+	for _, code := range platformErrorCodes {
+		body = fmt.Sprintf(`{
+			"error": {
+				"status": %q,
+				"message": "Test error message"
+			}
+		}`, code)
+
+		resp, err := client.Do(context.Background(), get)
+		if resp != nil || err == nil || err.Error() != want {
+			t.Fatalf("[%s]: Do() = (%v, %v); want = (nil, %q)", code, resp, err, want)
+		}
+		if !HasPlatformErrorCode(err, code) {
+			t.Errorf("[%s]: HasPlatformErrorCode() = false; want = true", code)
+		}
+
+		fe, ok := err.(*FirebaseError)
+		if !ok {
+			t.Fatalf("[%s]: Do() err = %v; want = FirebaseError", code, err)
+		}
+
+		if fe.ErrorCode != code {
+			t.Errorf("[%s]: Do() err.ErrorCode = %q; want = %q", code, fe.ErrorCode, code)
+		}
+		if fe.Response == nil {
+			t.Fatalf("[%s]: Do() err.Response = nil; want = non-nil", code)
+		}
+		if fe.Response.StatusCode != http.StatusNotFound {
+			t.Errorf("[%s]: Do() err.Response.StatusCode = %d; want = %d", code, fe.Response.StatusCode, http.StatusNotFound)
+		}
+		if fe.Ext == nil || len(fe.Ext) > 0 {
+			t.Errorf("[%s]: Do() err.Ext = %v; want = empty-map", code, fe.Ext)
+		}
+	}
+}
+
+func TestPlatformErrorWithoutDetails(t *testing.T) {
+	var status int
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(status)
+		w.Write([]byte("{}"))
+	})
+	server := httptest.NewServer(handler)
+	defer server.Close()
+
+	client := &HTTPClient{
+		Client: http.DefaultClient,
+	}
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    server.URL,
+	}
+
+	httpStatusMappings := map[int]ErrorCode{
+		http.StatusNotImplemented: Unknown,
+	}
+
+	// Add known error code mappings
+	for k, v := range httpStatusToErrorCodes {
+		httpStatusMappings[k] = v
+	}
+
+	for httpStatus, platformCode := range httpStatusMappings {
+		status = httpStatus
+		want := fmt.Sprintf("unexpected http response with status: %d\n{}", httpStatus)
+
+		resp, err := client.Do(context.Background(), get)
+		if resp != nil || err == nil || err.Error() != want {
+			t.Fatalf("[%d]: Do() = (%v, %v); want = (nil, %q)", httpStatus, resp, err, want)
+		}
+		if !HasPlatformErrorCode(err, platformCode) {
+			t.Errorf("[%d]: HasPlatformErrorCode(%q) = false; want = true", httpStatus, platformCode)
+		}
+
+		fe, ok := err.(*FirebaseError)
+		if !ok {
+			t.Fatalf("[%d]: Do() err = %v; want = FirebaseError", httpStatus, err)
+		}
+
+		if fe.ErrorCode != platformCode {
+			t.Errorf("[%d]: Do() err.ErrorCode = %q; want = %q", httpStatus, fe.ErrorCode, platformCode)
+		}
+		if fe.Response == nil {
+			t.Fatalf("[%d]: Do() err.Response = nil; want = non-nil", httpStatus)
+		}
+		if fe.Response.StatusCode != httpStatus {
+			t.Errorf("[%d]: Do() err.Response.StatusCode = %d; want = %d", httpStatus, fe.Response.StatusCode, httpStatus)
+		}
+		if fe.Ext == nil || len(fe.Ext) > 0 {
+			t.Errorf("[%d]: Do() err.Ext = %v; want = empty-map", httpStatus, fe.Ext)
+		}
+	}
+}
+
+func TestTimeoutError(t *testing.T) {
+	client := &HTTPClient{
+		Client: &http.Client{
+			Transport: &faultyTransport{
+				Err: &timeoutError{},
+			},
+		},
+	}
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    "http://test.url",
+	}
+	want := "timed out while making an http call"
+
+	resp, err := client.Do(context.Background(), get)
+	if resp != nil || err == nil || !strings.HasPrefix(err.Error(), want) {
+		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
+	}
+
+	fe, ok := err.(*FirebaseError)
+	if !ok {
+		t.Fatalf("Do() err = %v; want = FirebaseError", err)
+	}
+
+	if fe.ErrorCode != DeadlineExceeded {
+		t.Errorf("Do() err.ErrorCode = %q; want = %q", fe.ErrorCode, DeadlineExceeded)
+	}
+	if fe.Response != nil {
+		t.Errorf("Do() err.Response = %v; want = nil", fe.Response)
+	}
+	if fe.Ext == nil || len(fe.Ext) > 0 {
+		t.Errorf("Do() err.Ext = %v; want = empty-map", fe.Ext)
+	}
+}
+
+type timeoutError struct{}
+
+func (t *timeoutError) Error() string {
+	return "test timeout error"
+}
+
+func (t *timeoutError) Timeout() bool {
+	return true
+}
+
+func TestNetworkOutageError(t *testing.T) {
+	errors := []struct {
+		name string
+		err  error
+	}{
+		{"NetDialError", &net.OpError{Op: "dial", Err: errors.New("test error")}},
+		{"NetReadError", &net.OpError{Op: "read", Err: errors.New("test error")}},
+		{
+			"WrappedNetReadError",
+			&net.OpError{
+				Op:  "test",
+				Err: &net.OpError{Op: "read", Err: errors.New("test error")},
+			},
+		},
+		{"ECONNREFUSED", syscall.ECONNREFUSED},
+	}
+
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    "http://test.url",
+	}
+	want := "failed to establish a connection"
+
+	for _, tc := range errors {
+		t.Run(tc.name, func(t *testing.T) {
+			client := &HTTPClient{
+				Client: &http.Client{
+					Transport: &faultyTransport{
+						Err: tc.err,
+					},
+				},
+			}
+
+			resp, err := client.Do(context.Background(), get)
+			if resp != nil || err == nil || !strings.HasPrefix(err.Error(), want) {
+				t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
+			}
+
+			fe, ok := err.(*FirebaseError)
+			if !ok {
+				t.Fatalf("Do() err = %v; want = FirebaseError", err)
+			}
+
+			if fe.ErrorCode != Unavailable {
+				t.Errorf("Do() err.ErrorCode = %q; want = %q", fe.ErrorCode, Unavailable)
+			}
+			if fe.Response != nil {
+				t.Errorf("Do() err.Response = %v; want = nil", fe.Response)
+			}
+			if fe.Ext == nil || len(fe.Ext) > 0 {
+				t.Errorf("Do() err.Ext = %v; want = empty-map", fe.Ext)
+			}
+		})
+	}
+}
+
+func TestUnknownNetworkError(t *testing.T) {
+	client := &HTTPClient{
+		Client: &http.Client{
+			Transport: &faultyTransport{
+				Err: errors.New("unknown error"),
+			},
+		},
+	}
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    "http://test.url",
+	}
+	want := "unknown error while making an http call"
+
+	resp, err := client.Do(context.Background(), get)
+	if resp != nil || err == nil || !strings.HasPrefix(err.Error(), want) {
+		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
+	}
+
+	fe, ok := err.(*FirebaseError)
+	if !ok {
+		t.Fatalf("Do() err = %v; want = FirebaseError", err)
+	}
+
+	if fe.ErrorCode != Unknown {
+		t.Errorf("Do() err.ErrorCode = %q; want = %q", fe.ErrorCode, Unknown)
+	}
+	if fe.Response != nil {
+		t.Errorf("Do() err.Response = %v; want = nil", fe.Response)
+	}
+	if fe.Ext == nil || len(fe.Ext) > 0 {
+		t.Errorf("Do() err.Ext = %v; want = empty-map", fe.Ext)
+	}
+}
+
+func TestErrorHTTPResponse(t *testing.T) {
+	body := `{"key": "value"}`
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(body))
+	})
+	server := httptest.NewServer(handler)
+	defer server.Close()
+
+	client := &HTTPClient{
+		Client: http.DefaultClient,
+	}
+	get := &Request{
+		Method: http.MethodGet,
+		URL:    server.URL,
+	}
+	want := fmt.Sprintf("unexpected http response with status: 500\n%s", body)
+
+	resp, err := client.Do(context.Background(), get)
+	if resp != nil || err == nil || err.Error() != want {
+		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
+	}
+
+	fe, ok := err.(*FirebaseError)
+	if !ok {
+		t.Fatalf("Do() err = %v; want = FirebaseError", err)
+	}
+
+	hr := fe.Response
+	defer hr.Body.Close()
+	if hr.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Do() Response.StatusCode = %d; want = %d", hr.StatusCode, http.StatusInternalServerError)
+	}
+
+	b, err := ioutil.ReadAll(hr.Body)
+	if err != nil {
+		t.Fatalf("ReadAll(Response.Body) = %v", err)
+	}
+
+	var m map[string]string
+	if err := json.Unmarshal(b, &m); err != nil {
+		t.Fatalf("Unmarshal(Response.Body) = %v", err)
+	}
+
+	if len(m) != 1 || m["key"] != "value" {
+		t.Errorf("Unmarshal(Response.Body) = %v; want = {key: value}", m)
+	}
+}
diff --git a/internal/http_client_test.go b/internal/http_client_test.go
index 4d5cfd5..fbb4afa 100644
--- a/internal/http_client_test.go
+++ b/internal/http_client_test.go
@@ -22,7 +22,6 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"reflect"
-	"strings"
 	"testing"
 	"time"
 
@@ -167,20 +166,13 @@ func TestHTTPClient(t *testing.T) {
 	client := &HTTPClient{Client: http.DefaultClient}
 	for _, tc := range testRequests {
 		tc.req.URL = server.URL
-		resp, err := client.Do(context.Background(), tc.req)
+		var got map[string]interface{}
+		resp, err := client.DoAndUnmarshal(context.Background(), tc.req, &got)
 		if err != nil {
 			t.Fatal(err)
 		}
-		if err := resp.CheckStatus(http.StatusOK); err != nil {
-			t.Errorf("CheckStatus() = %v; want nil", err)
-		}
-		if err := resp.CheckStatus(http.StatusCreated); err == nil {
-			t.Errorf("CheckStatus() = nil; want error")
-		}
-
-		var got map[string]interface{}
-		if err := resp.Unmarshal(http.StatusOK, &got); err != nil {
-			t.Errorf("Unmarshal() = %v; want nil", err)
+		if resp.Status != http.StatusOK {
+			t.Errorf("Status = %d; want = %d", resp.Status, http.StatusOK)
 		}
 		if !reflect.DeepEqual(got, want) {
 			t.Errorf("Body = %v; want = %v", got, want)
@@ -238,108 +230,55 @@ func TestSuccessFn(t *testing.T) {
 		Method: http.MethodGet,
 		URL:    server.URL,
 	}
-	want := "unexpected http response with status: 200; body: {}"
+	want := "unexpected http response with status: 200\n{}"
 
 	resp, err := client.Do(context.Background(), get)
 	if resp != nil || err == nil || err.Error() != want {
 		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
 	}
 
-	if !HasErrorCode(err, "UNKNOWN") {
-		t.Errorf("ErrorCode = %q; want = %q", err.(*FirebaseError).Code, "UNKNOWN")
+	fe, ok := err.(*FirebaseError)
+	if !ok {
+		t.Fatalf("Do() err = %v; want = FirebaseError", err)
 	}
-}
-
-func TestSuccessFnOnRequest(t *testing.T) {
-	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Write([]byte("{}"))
-	})
-	server := httptest.NewServer(handler)
-	defer server.Close()
 
-	client := &HTTPClient{
-		Client:    http.DefaultClient,
-		SuccessFn: HasSuccessStatus,
+	if fe.ErrorCode != Unknown {
+		t.Errorf("Do() err.ErrorCode = %q; want = %q", fe.ErrorCode, Unknown)
 	}
-	get := &Request{
-		Method: http.MethodGet,
-		URL:    server.URL,
-		SuccessFn: func(r *Response) bool {
-			return false
-		},
+	if fe.Response == nil {
+		t.Fatalf("Do() err.Response = nil; want = non-nil")
 	}
-	want := "unexpected http response with status: 200; body: {}"
-
-	resp, err := client.Do(context.Background(), get)
-	if resp != nil || err == nil || err.Error() != want {
-		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
-	}
-
-	if !HasErrorCode(err, "UNKNOWN") {
-		t.Errorf("ErrorCode = %q; want = %q", err.(*FirebaseError).Code, "UNKNOWN")
+	if fe.Response.StatusCode != http.StatusOK {
+		t.Errorf("Do() err.Response.StatusCode = %d; want = %d", fe.Response.StatusCode, http.StatusOK)
 	}
 }
 
-func TestPlatformError(t *testing.T) {
-	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		resp := `{
-			"error": {
-				"status": "NOT_FOUND",
-				"message": "Requested entity not found"
-			}
-		}`
-
-		w.WriteHeader(http.StatusNotFound)
-		w.Write([]byte(resp))
-	})
-	server := httptest.NewServer(handler)
-	defer server.Close()
-
-	client := &HTTPClient{
-		Client:    http.DefaultClient,
-		SuccessFn: HasSuccessStatus,
-	}
-	get := &Request{
-		Method: http.MethodGet,
-		URL:    server.URL,
-	}
-	want := "Requested entity not found"
-
-	resp, err := client.Do(context.Background(), get)
-	if resp != nil || err == nil || err.Error() != want {
-		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
-	}
-
-	if !HasErrorCode(err, "NOT_FOUND") {
-		t.Errorf("ErrorCode = %q; want = %q", err.(*FirebaseError).Code, "NOT_FOUND")
-	}
-}
-
-func TestPlatformErrorWithoutDetails(t *testing.T) {
+func TestSuccessFnOnRequest(t *testing.T) {
 	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusNotFound)
 		w.Write([]byte("{}"))
 	})
 	server := httptest.NewServer(handler)
 	defer server.Close()
 
 	client := &HTTPClient{
-		Client:    http.DefaultClient,
-		SuccessFn: HasSuccessStatus,
+		Client: http.DefaultClient,
 	}
 	get := &Request{
 		Method: http.MethodGet,
 		URL:    server.URL,
+		SuccessFn: func(r *Response) bool {
+			return false
+		},
 	}
-	want := "unexpected http response with status: 404; body: {}"
+	want := "unexpected http response with status: 200\n{}"
 
 	resp, err := client.Do(context.Background(), get)
 	if resp != nil || err == nil || err.Error() != want {
 		t.Fatalf("Do() = (%v, %v); want = (nil, %q)", resp, err, want)
 	}
 
-	if !HasErrorCode(err, "UNKNOWN") {
-		t.Errorf("ErrorCode = %q; want = %q", err.(*FirebaseError).Code, "UNKNOWN")
+	if !HasPlatformErrorCode(err, Unknown) {
+		t.Errorf("ErrorCode = %q; want = %q", err.(*FirebaseError).ErrorCode, Unknown)
 	}
 }
 
@@ -356,7 +295,6 @@ func TestCreateErrFn(t *testing.T) {
 		CreateErrFn: func(r *Response) error {
 			return fmt.Errorf("custom error with status: %d", r.Status)
 		},
-		SuccessFn: HasSuccessStatus,
 	}
 	get := &Request{
 		Method: http.MethodGet,
@@ -383,7 +321,6 @@ func TestCreateErrFnOnRequest(t *testing.T) {
 		CreateErrFn: func(r *Response) error {
 			return fmt.Errorf("custom error with status: %d", r.Status)
 		},
-		SuccessFn: HasSuccessStatus,
 	}
 	get := &Request{
 		Method: http.MethodGet,
@@ -417,9 +354,6 @@ func TestContext(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := resp.CheckStatus(http.StatusOK); err != nil {
-		t.Fatal(err)
-	}
 
 	cancel()
 	resp, err = client.Do(ctx, &Request{
@@ -431,55 +365,6 @@ func TestContext(t *testing.T) {
 	}
 }
 
-func TestErrorParser(t *testing.T) {
-	data := map[string]interface{}{
-		"error": "test error",
-	}
-	b, err := json.Marshal(data)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusInternalServerError)
-		w.Header().Set("Content-Type", "application/json")
-		w.Write(b)
-	})
-	server := httptest.NewServer(handler)
-	defer server.Close()
-
-	ep := func(b []byte) string {
-		var p struct {
-			Error string `json:"error"`
-		}
-		if err := json.Unmarshal(b, &p); err != nil {
-			return ""
-		}
-		return p.Error
-	}
-	client := &HTTPClient{
-		Client:    http.DefaultClient,
-		ErrParser: ep,
-	}
-	req := &Request{Method: http.MethodGet, URL: server.URL}
-	resp, err := client.Do(context.Background(), req)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	want := "http error status: 500; reason: test error"
-	if err := resp.CheckStatus(http.StatusOK); err.Error() != want {
-		t.Errorf("CheckStatus() = %q; want = %q", err.Error(), want)
-	}
-	var got map[string]interface{}
-	if err := resp.Unmarshal(http.StatusOK, &got); err.Error() != want {
-		t.Errorf("CheckStatus() = %q; want = %q", err.Error(), want)
-	}
-	if got != nil {
-		t.Errorf("Body = %v; want = nil", got)
-	}
-}
-
 func TestInvalidURL(t *testing.T) {
 	req := &Request{
 		Method: http.MethodGet,
@@ -509,14 +394,10 @@ func TestUnmarshalError(t *testing.T) {
 
 	req := &Request{Method: http.MethodGet, URL: server.URL}
 	client := &HTTPClient{Client: http.DefaultClient}
-	resp, err := client.Do(context.Background(), req)
-	if err != nil {
-		t.Fatal(err)
-	}
-
 	var got func()
-	if err := resp.Unmarshal(http.StatusOK, &got); err == nil {
-		t.Errorf("Unmarshal() = nil; want error")
+	_, err = client.DoAndUnmarshal(context.Background(), req, &got)
+	if err == nil {
+		t.Errorf("DoAndUnmarshal() = nil; want error")
 	}
 }
 
@@ -534,14 +415,15 @@ func TestRetryDisabled(t *testing.T) {
 	client := &HTTPClient{
 		Client:      http.DefaultClient,
 		RetryConfig: nil,
+		SuccessFn:   acceptAll,
 	}
 	req := &Request{Method: http.MethodGet, URL: server.URL}
 	resp, err := client.Do(context.Background(), req)
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := resp.CheckStatus(http.StatusServiceUnavailable); err != nil {
-		t.Errorf("CheckStatus() = %q; want = nil", err.Error())
+	if resp.Status != http.StatusServiceUnavailable {
+		t.Errorf("Status = %d; want = %d", resp.Status, http.StatusServiceUnavailable)
 	}
 	if requests != 1 {
 		t.Errorf("Total requests = %d; want = 1", requests)
@@ -848,7 +730,9 @@ func TestNewHTTPClientRetryOnHTTPErrors(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+
 	client.RetryConfig.ExpBackoffFactor = 0
+	client.SuccessFn = acceptAll
 	for _, status = range []int{http.StatusInternalServerError, http.StatusServiceUnavailable} {
 		requests = 0
 		req := &Request{Method: http.MethodGet, URL: server.URL}
@@ -856,8 +740,8 @@ func TestNewHTTPClientRetryOnHTTPErrors(t *testing.T) {
 		if err != nil {
 			t.Fatal(err)
 		}
-		if err := resp.CheckStatus(status); err != nil {
-			t.Errorf("CheckStatus(%d) = %q; want = nil", status, err.Error())
+		if resp.Status != status {
+			t.Errorf("Status = %d; want = %d", resp.Status, status)
 		}
 		wantRequests := 1 + defaultMaxRetries
 		if requests != wantRequests {
@@ -881,13 +765,16 @@ func TestNewHttpClientNoRetryOnNotFound(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+
+	client.SuccessFn = acceptAll
 	req := &Request{Method: http.MethodGet, URL: server.URL}
 	resp, err := client.Do(context.Background(), req)
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := resp.CheckStatus(http.StatusNotFound); err != nil {
-		t.Errorf("CheckStatus() = %q; want = nil", err.Error())
+
+	if resp.Status != http.StatusNotFound {
+		t.Errorf("Status = %d; want = %d", resp.Status, http.StatusNotFound)
 	}
 	if requests != 1 {
 		t.Errorf("Total requests = %d; want = 1", requests)
@@ -909,12 +796,11 @@ func TestNewHttpClientRetryOnResponseReadError(t *testing.T) {
 		t.Fatal(err)
 	}
 	client.RetryConfig.ExpBackoffFactor = 0
-	wantPrefix := "error while making http call: "
 
 	req := &Request{Method: http.MethodGet, URL: server.URL}
 	resp, err := client.Do(context.Background(), req)
-	if resp != nil || err == nil || !strings.HasPrefix(err.Error(), wantPrefix) {
-		t.Errorf("Do() = (%v, %v); want = (nil, %q)", resp, err, wantPrefix)
+	if resp != nil || err == nil {
+		t.Errorf("Do() = (%v, %v); want = (nil, error)", resp, err)
 	}
 
 	wantRequests := 1 + defaultMaxRetries
@@ -923,6 +809,16 @@ func TestNewHttpClientRetryOnResponseReadError(t *testing.T) {
 	}
 }
 
+func TestNilLowLevelResponse(t *testing.T) {
+	r := &Response{
+		resp: nil,
+	}
+
+	if ll := r.LowLevelResponse(); ll != nil {
+		t.Errorf("LowLevelResponse() = %v; want = nil", ll)
+	}
+}
+
 type faultyEntity struct {
 	RequestAttempts int
 }
@@ -938,9 +834,18 @@ func (e *faultyEntity) Mime() string {
 
 type faultyTransport struct {
 	RequestAttempts int
+	Err             error
 }
 
 func (e *faultyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 	e.RequestAttempts++
+	if e.Err != nil {
+		return nil, e.Err
+	}
+
 	return nil, errors.New("test error")
 }
+
+func acceptAll(resp *Response) bool {
+	return true
+}
diff --git a/internal/json_http_client_test.go b/internal/json_http_client_test.go
index 53c9da7..15ebed7 100644
--- a/internal/json_http_client_test.go
+++ b/internal/json_http_client_test.go
@@ -185,11 +185,10 @@ func TestDoAndUnmarshalTransportError(t *testing.T) {
 		URL:    server.URL,
 	}
 	var data interface{}
-	wantPrefix := "error while making http call: "
 
 	resp, err := client.DoAndUnmarshal(context.Background(), get, &data)
-	if resp != nil || err == nil || !strings.HasPrefix(err.Error(), wantPrefix) {
-		t.Errorf("DoAndUnmarshal() = (%v, %v); want = (nil, %q)", resp, err, wantPrefix)
+	if resp != nil || err == nil {
+		t.Errorf("DoAndUnmarshal() = (%v, %v); want = (nil, error)", resp, err)
 	}
 
 	if data != nil {
diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go
index be96651..477db6c 100644
--- a/messaging/messaging_test.go
+++ b/messaging/messaging_test.go
@@ -24,7 +24,8 @@ import (
 	"testing"
 	"time"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/errorutils"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 )
 
@@ -311,7 +312,7 @@ var validMessages = []struct {
 					Vibrate:            []int{100, 200, 100},
 					CustomData:         map[string]interface{}{"k1": "v1", "k2": "v2"},
 				},
-				FcmOptions: &WebpushFcmOptions{
+				FCMOptions: &WebpushFCMOptions{
 					Link: "https://link.com",
 				},
 			},
@@ -959,7 +960,7 @@ var invalidMessages = []struct {
 		req: &Message{
 			Webpush: &WebpushConfig{
 				Notification: &WebpushNotification{},
-				FcmOptions: &WebpushFcmOptions{
+				FCMOptions: &WebpushFCMOptions{
 					Link: "link",
 				},
 			},
@@ -972,7 +973,7 @@ var invalidMessages = []struct {
 		req: &Message{
 			Webpush: &WebpushConfig{
 				Notification: &WebpushNotification{},
-				FcmOptions: &WebpushFcmOptions{
+				FCMOptions: &WebpushFCMOptions{
 					Link: "http://link.com",
 				},
 			},
@@ -1209,11 +1210,11 @@ func TestSendError(t *testing.T) {
 	client.fcmEndpoint = ts.URL
 	client.fcmClient.httpClient.RetryConfig = nil
 
-	for _, tc := range httpErrors {
+	for idx, tc := range httpErrors {
 		resp = tc.resp
 		name, err := client.Send(ctx, &Message{Topic: "topic"})
 		if err == nil || err.Error() != tc.want || !tc.check(err) {
-			t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want)
+			t.Errorf("Send(%d) = (%q, %v); want = (%q, %q)", idx, name, err, "", tc.want)
 		}
 	}
 }
@@ -1277,60 +1278,89 @@ var httpErrors = []struct {
 }{
 	{
 		resp:  "{}",
-		want:  "http error status: 500; reason: server responded with an unknown error; response: {}",
-		check: IsUnknown,
+		want:  "unexpected http response with status: 500\n{}",
+		check: errorutils.IsInternal,
 	},
 	{
 		resp:  "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}",
-		want:  "http error status: 500; reason: request contains an invalid argument; code: invalid-argument; details: test error",
-		check: IsInvalidArgument,
+		want:  "test error",
+		check: errorutils.IsInvalidArgument,
 	},
 	{
-		resp: "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: app instance has been unregistered; code: registration-token-not-registered; " +
-			"details: test error",
-		check: IsRegistrationTokenNotRegistered,
+		resp:  "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}",
+		want:  "test error",
+		check: errorutils.IsNotFound,
 	},
 	{
-		resp: "{\"error\": {\"status\": \"QUOTA_EXCEEDED\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: messaging service quota exceeded; code: message-rate-exceeded; " +
-			"details: test error",
-		check: IsMessageRateExceeded,
+		resp:  "{\"error\": {\"status\": \"RESOURCE_EXHAUSTED\", \"message\": \"test error\"}}",
+		want:  "test error",
+		check: errorutils.IsResourceExhausted,
 	},
 	{
-		resp: "{\"error\": {\"status\": \"UNAVAILABLE\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: backend servers are temporarily unavailable; code: server-unavailable; " +
-			"details: test error",
-		check: IsServerUnavailable,
+		resp:  "{\"error\": {\"status\": \"UNAVAILABLE\", \"message\": \"test error\"}}",
+		want:  "test error",
+		check: errorutils.IsUnavailable,
 	},
 	{
-		resp: "{\"error\": {\"status\": \"INTERNAL\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: backend servers encountered an unknown internl error; code: internal-error; " +
-			"details: test error",
-		check: IsInternal,
+		resp:  "{\"error\": {\"status\": \"INTERNAL\", \"message\": \"test error\"}}",
+		want:  "test error",
+		check: errorutils.IsInternal,
+	},
+	{
+		resp: `{"error": {"status": "INVALID_ARGUMENT", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED"}]}}`,
+		want: "test error",
+		check: func(err error) bool {
+			return IsRegistrationTokenNotRegistered(err) && IsUnregistered(err)
+		},
+	},
+	{
+		resp: `{"error": {"status": "INVALID_ARGUMENT", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "SENDER_ID_MISMATCH"}]}}`,
+		want: "test error",
+		check: func(err error) bool {
+			return IsMismatchedCredential(err) && IsSenderIDMismatch(err)
+		},
 	},
 	{
-		resp: "{\"error\": {\"status\": \"APNS_AUTH_ERROR\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: apns certificate or auth key was invalid; code: invalid-apns-credentials; " +
-			"details: test error",
-		check: IsInvalidAPNSCredentials,
+		resp: `{"error": {"status": "RESOURCE_EXHAUSTED", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "QUOTA_EXCEEDED"}]}}`,
+		want: "test error",
+		check: func(err error) bool {
+			return IsMessageRateExceeded(err) && IsQuotaExceeded(err)
+		},
+	},
+	{
+		resp: `{"error": {"status": "UNAVAILABLE", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNAVAILABLE"}]}}`,
+		want: "test error",
+		check: func(err error) bool {
+			return IsServerUnavailable(err) && IsUnavailable(err)
+		},
 	},
 	{
-		resp: "{\"error\": {\"status\": \"SENDER_ID_MISMATCH\", \"message\": \"test error\"}}",
-		want: "http error status: 500; reason: sender id does not match registration token; code: mismatched-credential; " +
-			"details: test error",
-		check: IsMismatchedCredential,
+		resp: `{"error": {"status": "INTERNAL", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "INTERNAL"}]}}`,
+		want:  "test error",
+		check: IsInternal,
 	},
 	{
 		resp: `{"error": {"status": "INVALID_ARGUMENT", "message": "test error", "details": [` +
-			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED"}]}}`,
-		want: "http error status: 500; reason: app instance has been unregistered; code: registration-token-not-registered; " +
-			"details: test error",
-		check: IsRegistrationTokenNotRegistered,
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "INVALID_ARGUMENT"}]}}`,
+		want:  "test error",
+		check: IsInvalidArgument,
+	},
+	{
+		resp: `{"error": {"status": "INVALID_ARGUMENT", "message": "test error", "details": [` +
+			`{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "THIRD_PARTY_AUTH_ERROR"}]}}`,
+		want: "test error",
+		check: func(err error) bool {
+			return IsInvalidAPNSCredentials(err) && IsThirdPartyAuthError(err)
+		},
 	},
 	{
 		resp:  "not json",
-		want:  "http error status: 500; reason: server responded with an unknown error; response: not json",
-		check: IsUnknown,
+		want:  "unexpected http response with status: 500\nnot json",
+		check: errorutils.IsInternal,
 	},
 }
diff --git a/messaging/topic_mgt_test.go b/messaging/topic_mgt_test.go
index 68a82a2..15ea45d 100644
--- a/messaging/topic_mgt_test.go
+++ b/messaging/topic_mgt_test.go
@@ -23,6 +23,8 @@ import (
 	"reflect"
 	"strings"
 	"testing"
+
+	"firebase.google.com/go/v4/errorutils"
 )
 
 func TestSubscribe(t *testing.T) {
@@ -113,8 +115,9 @@ func TestInvalidUnsubscribe(t *testing.T) {
 
 func TestTopicManagementError(t *testing.T) {
 	var resp string
+	var status int
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusInternalServerError)
+		w.WriteHeader(status)
 		w.Header().Set("Content-Type", "application/json")
 		w.Write([]byte(resp))
 	}))
@@ -129,42 +132,45 @@ func TestTopicManagementError(t *testing.T) {
 	client.iidClient.httpClient.RetryConfig = nil
 
 	cases := []struct {
-		resp, want string
-		check      func(error) bool
+		name, resp, want string
+		status           int
+		check            func(err error) bool
 	}{
 		{
-			resp:  "{}",
-			want:  "http error status: 500; reason: client encountered an unknown error; response: {}",
-			check: IsUnknown,
-		},
-		{
-			resp:  "{\"error\": \"INVALID_ARGUMENT\"}",
-			want:  "http error status: 500; reason: request contains an invalid argument; code: invalid-argument",
-			check: IsInvalidArgument,
+			name:   "EmptyResponse",
+			resp:   "{}",
+			want:   "unexpected http response with status: 500\n{}",
+			status: http.StatusInternalServerError,
+			check:  errorutils.IsInternal,
 		},
 		{
-			resp:  "{\"error\": \"TOO_MANY_TOPICS\"}",
-			want:  "http error status: 500; reason: client exceeded the number of allowed topics; code: too-many-topics",
-			check: IsTooManyTopics,
+			name:   "ErrorCode",
+			resp:   "{\"error\": \"INVALID_ARGUMENT\"}",
+			want:   "error while calling the iid service: INVALID_ARGUMENT",
+			status: http.StatusBadRequest,
+			check:  errorutils.IsInvalidArgument,
 		},
 		{
-			resp:  "not json",
-			want:  "http error status: 500; reason: client encountered an unknown error; response: not json",
-			check: IsUnknown,
+			name:   "NotJson",
+			resp:   "not json",
+			want:   "unexpected http response with status: 500\nnot json",
+			status: http.StatusInternalServerError,
+			check:  errorutils.IsInternal,
 		},
 	}
+
 	for _, tc := range cases {
 		resp = tc.resp
+		status = tc.status
+
 		tmr, err := client.SubscribeToTopic(ctx, []string{"id1"}, "topic")
 		if err == nil || err.Error() != tc.want || !tc.check(err) {
-			t.Errorf("SubscribeToTopic() = (%#v, %v); want = (nil, %q)", tmr, err, tc.want)
+			t.Errorf("SubscribeToTopic(%s) = (%#v, %v); want = (nil, %q)", tc.name, tmr, err, tc.want)
 		}
-	}
-	for _, tc := range cases {
-		resp = tc.resp
-		tmr, err := client.UnsubscribeFromTopic(ctx, []string{"id1"}, "topic")
-		if err == nil || err.Error() != tc.want {
-			t.Errorf("UnsubscribeFromTopic() = (%#v, %v); want = (nil, %q)", tmr, err, tc.want)
+
+		tmr, err = client.UnsubscribeFromTopic(ctx, []string{"id1"}, "topic")
+		if err == nil || err.Error() != tc.want || !tc.check(err) {
+			t.Errorf("UnsubscribeFromTopic(%s) = (%#v, %v); want = (nil, %q)", tc.name, tmr, err, tc.want)
 		}
 	}
 }
@@ -208,8 +214,8 @@ func checkTopicMgtResponse(t *testing.T, resp *TopicManagementResponse) {
 	if e.Index != 1 {
 		t.Errorf("ErrorInfo.Index = %d; want = %d", e.Index, 1)
 	}
-	if e.Reason != "unknown-error" {
-		t.Errorf("ErrorInfo.Reason = %s; want = %s", e.Reason, "unknown-error")
+	if e.Reason != "error_reason" {
+		t.Errorf("ErrorInfo.Reason = %s; want = %s", e.Reason, "error_reason")
 	}
 }
 
diff --git a/storage/storage_test.go b/storage/storage_test.go
index 52a64f4..ab57f1d 100644
--- a/storage/storage_test.go
+++ b/storage/storage_test.go
@@ -18,7 +18,7 @@ import (
 	"context"
 	"testing"
 
-	"firebase.google.com/go/internal"
+	"firebase.google.com/go/v4/internal"
 	"google.golang.org/api/option"
 )