Skip to content

fix(storage emulator): don't report request failures as "document not found" in firestore.get() - #10883

Open
jon-comley wants to merge 1 commit into
firebase:mainfrom
jon-comley:fix/storage-rules-firestore-get-emulator-startup-race
Open

fix(storage emulator): don't report request failures as "document not found" in firestore.get()#10883
jon-comley wants to merge 1 commit into
firebase:mainfrom
jon-comley:fix/storage-rules-firestore-get-emulator-startup-race

Conversation

@jon-comley

@jon-comley jon-comley commented Aug 3, 2026

Copy link
Copy Markdown

Description

fetchFirestoreDocument() in src/emulator/storage/rules/runtime.ts the path
backing firestore.get() / firestore.exists() inside Storage rules collapses
every possible failure into "the document does not exist":

try {
  const doc = await client.get(pathname);
  const { name, fields } = doc.body as { name: string; fields: string };
  const result = { name, fields };
  return { result, status: DataLoadStatus.OK, warnings: [], errors: [] };
} catch (e) {
  // Don't care what the error is, just return not_found
  return { status: DataLoadStatus.NOT_FOUND, warnings: [], errors: [] };
}

A confirmed HTTP 404 and a request that never reached a healthy server are
reported identically, with no retry and nothing logged. So a connection refusal,
a timeout, or a malformed response all cause a rule like:

allow write: if firestore.get(/databases/(default)/documents/jobs/$(jobId)).data.ownerId == request.auth.uid;

to evaluate as though the document is absent, and deny the write indistinguishable,
from the caller's side, from a genuine permissions failure.

Because the try also wraps the response destructuring, a malformed payload
(e.g. a null body) throws a TypeError that lands in the same catch and is
likewise reported as "not found", hiding a schema or programming error.

Fix

  • Only a confirmed HTTP 404 is treated as a real "not found", returned
    immediately so the common "does this exist yet" path is unchanged and stays fast.
  • Any other failure is retried a small, bounded number of times before giving up,
    on the grounds that it reflects the request not completing rather than an answer
    about the document.
  • Response parsing moves outside the try, so a malformed payload surfaces as
    the error it is instead of being retried as a network problem.
  • Errors are narrowed with a hasHttpStatus type guard rather than any.

Scope and honesty about evidence

I hit a run of failures where Storage rules using firestore.get() behaved as
though the referenced document was missing under emulators:exec, which is what
led me here. I have not been able to reproduce that failure on demand since
I tried a dropped page cache, cold JARs, and heavy CPU contention, and the
unpatched code passed every time. I also never captured the swallowed error
itself, so I can't tell you which error it was.

I've therefore deliberately avoided asserting a specific root cause in this PR.
What I'm proposing stands on the code as written: conflating "the request failed"
with "the document doesn't exist" produces incorrect rule evaluations, and the
current code cannot distinguish them. If maintainers consider the existing
behaviour intentional, I'm happy to close this.

Testing

Unit tests added in src/emulator/storage/rules/runtime.spec.ts:

  • successful first attempt (no retries)
  • confirmed 404 returns immediately without retrying
  • transient connection failure retried, then succeeds
  • malformed response body returns NOT_FOUND after exactly one call (no retries)
  • persistent non-404 failure gives up and returns NOT_FOUND

npm run test:compile and npm run lint:quiet pass; the runtime.spec.ts suite
(11 tests) passes locally. I have not run the integration suites, which need
emulators and credentials I don't have configured.

@google-cla

google-cla Bot commented Aug 3, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a retry mechanism for firestore.get() and firestore.exists() calls in Storage rules to resolve intermittent connection failures during Firestore emulator startup. Feedback on the changes highlights a style guide violation regarding the use of any in the catch block. Additionally, the reviewer recommended separating the network request from response parsing to prevent standard JavaScript errors (such as TypeError) from incorrectly triggering retry attempts.

Comment on lines +516 to 533
for (let attempt = 1; attempt <= FETCH_FIRESTORE_DOCUMENT_MAX_ATTEMPTS; attempt++) {
try {
const doc = await client.get(pathname);
const { name, fields } = doc.body as { name: string; fields: string };
const result = { name, fields };
return { result, status: DataLoadStatus.OK, warnings: [], errors: [] };
} catch (e: any) {
// A confirmed 404 from a server that's actually up means the document
// genuinely doesn't exist — that's a real answer, not a symptom of the
// emulator still starting up, so it's returned immediately either way.
const isConfirmedNotFound = e?.status === 404;
const isLastAttempt = attempt === FETCH_FIRESTORE_DOCUMENT_MAX_ATTEMPTS;
if (isConfirmedNotFound || isLastAttempt) {
return { status: DataLoadStatus.NOT_FOUND, warnings: [], errors: [] };
}
await utils.sleep(FETCH_FIRESTORE_DOCUMENT_RETRY_DELAY_MS);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation uses e: any in the catch block, which violates the repository style guide rule: Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards.

Additionally, performing response destructuring inside the try block means standard JS errors (such as a TypeError if doc.body is null or undefined) will be caught and trigger retry attempts and sleeping. This is inefficient and masks programming/schema errors as transient network issues.

To address both issues, we can:

  1. Type e as unknown and use a safe type assertion to check the status code.
  2. Separate the network request (client.get) from the response parsing.
  3. Add defensive checks to ensure doc.body and body.name are valid before using them.
  for (let attempt = 1; attempt <= FETCH_FIRESTORE_DOCUMENT_MAX_ATTEMPTS; attempt++) {
    let doc;
    try {
      doc = await client.get(pathname);
    } catch (e: unknown) {
      const isConfirmedNotFound =
        typeof e === "object" &&
        e !== null &&
        "status" in e &&
        (e as { status?: number }).status === 404;
      const isLastAttempt = attempt === FETCH_FIRESTORE_DOCUMENT_MAX_ATTEMPTS;
      if (isConfirmedNotFound || isLastAttempt) {
        return { status: DataLoadStatus.NOT_FOUND, warnings: [], errors: [] };
      }
      await utils.sleep(FETCH_FIRESTORE_DOCUMENT_RETRY_DELAY_MS);
      continue;
    }

    const body = doc.body as { name?: string; fields?: unknown } | undefined;
    if (!body || typeof body.name !== "string") {
      return { status: DataLoadStatus.NOT_FOUND, warnings: [], errors: [] };
    }
    const result = { name: body.name, fields: body.fields };
    return { result, status: DataLoadStatus.OK, warnings: [], errors: [] };
  }
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

@jon-comley

Copy link
Copy Markdown
Author

@googlebot I signed it!

@jon-comley
jon-comley force-pushed the fix/storage-rules-firestore-get-emulator-startup-race branch from 90f9f80 to 87b9109 Compare August 3, 2026 00:49
@jon-comley

Copy link
Copy Markdown
Author

@googlebot I signed it!

@jon-comley jon-comley changed the title fix(storage emulator): retry firestore.get()/exists() on transient startup race fix(storage emulator): don't report request failures as "document not found" in firestore.get() Aug 3, 2026
… found"

fetchFirestoreDocument() backs firestore.get()/firestore.exists() inside
Storage rules. It caught every error from its request to the Firestore
emulator and returned DataLoadStatus.NOT_FOUND, with no retry and nothing
logged — so a confirmed 404 and a request that never reached a healthy
server were reported identically. A rule gating on firestore.get() would
then deny access on the strength of a transport failure.

The try also wrapped the response destructuring, so a malformed payload
(e.g. a null body) threw a TypeError into the same catch and was likewise
reported as "not found", hiding a schema or programming error.

- Only a confirmed HTTP 404 is treated as a real answer and returned
  immediately, leaving the common "does this exist yet" path unchanged.
- Other failures are retried a small, bounded number of times, since a
  request that didn't complete says nothing about the document.
- Response parsing moved outside the try, with a defensive check on
  body.name.
- Errors narrowed with a hasHttpStatus type guard rather than `any`.

Adds unit tests for the success path, the confirmed-404 fast path, a
retried-then-successful connection failure, a malformed body, and a
persistent non-404 failure.
@jon-comley
jon-comley force-pushed the fix/storage-rules-firestore-get-emulator-startup-race branch from 87b9109 to 8a0990c Compare August 3, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants