Skip to content

Bug: eventFilterMatches returns false for nested $exists: false when the parent key is missing or null #5017

Open

Description

@devin-ai-integration

Summary

eventFilterMatches returns false for a nested $exists: false filter when the parent key is missing or null in the payload. The function returns false for any non-empty filter as soon as it meets an undefined/null payload, before it evaluates $exists. This is user-facing through retry.fetch's bodyFilter, which calls eventFilterMatches on the response body.

Reproduction

  1. Setup: packages/core on main.
  2. Command: npx tsx repro.ts from packages/core with:
import { eventFilterMatches } from "./src/eventFilterMatches.js";
const f = { user: { email: [{ $exists: false }] } };
console.log(eventFilterMatches({}, f));             // false
console.log(eventFilterMatches({ user: null }, f)); // false
console.log(eventFilterMatches({ user: {} }, f));   // true
console.log(eventFilterMatches({}, { email: [{ $exists: false }] })); // true
  1. Trigger: a nested filter whose leaf is $exists: false, used with a payload where an ancestor key is absent or null.

Actual behavior

{} {"user":{"email":[{"$exists":false}]}} => false
{"user":null} {"user":{"email":[{"$exists":false}]}} => false
{"user":{}} {"user":{"email":[{"$exists":false}]}} => true
{} {"email":[{"$exists":false}]} => true

Expected behavior

All four should return true. If user is missing, then user.email doesn't exist either, which is exactly what $exists: false asserts. Top-level $exists: false already behaves that way (case 4, and the existing test "should return true when payload does NOT have a key that $exists = false"). Nesting the same condition one level deeper shouldn't flip the result.

Environment

  • Version/commit: main @ 93465d8
  • OS: Ubuntu (Linux), Node v24.21.0
  • Relevant configuration: none; pure function. Reachable from retry.fetch(url, { retry: { byStatus: { "...": { strategy: "backoff", bodyFilter } } } })

Evidence

  • Early return for undefined/null payload with a non-empty filter:
    export function eventFilterMatches(payload: any, filter: EventFilter): boolean {
    if (payload === undefined || payload === null) {
    if (Object.entries(filter).length === 0) {
    return true;
    } else {
    return false;
    }
    }
  • Recursion into nested object filters:
    continue;
    } else if (typeof patternValue === "object") {
    if (Array.isArray(payloadValue)) {
    if (!payloadValue.some((item) => eventFilterMatches(item, patternValue))) {
    return false;
    }
    } else {
    if (!eventFilterMatches(payloadValue, patternValue)) {
    return false;
    }
    }
  • retry.fetch bodyFilter path:
    if (strategy.bodyFilter) {
    const body = safeJsonParse(await clonedResponse.text());
    if (!body) {
    continue;
    }
    if (eventFilterMatches(body, strategy.bodyFilter)) {
    return strategy;
    } else {
    continue;
    }
  • bodyFilter: EventFilter in the fetch retry schema:
    bodyFilter: EventFilter.optional(),
    and
    bodyFilter: EventFilter.optional(),
  • Existing tests cover only top-level $exists:
    it("should return true when payload does NOT have a key that $exists = false", () => {
    const payload = {
    name: "John",
    age: 30,
    isAdmin: false,
    hobbies: ["reading", "swimming"],
    address: {
    street: "123 Main St",
    city: "Anytown",
    state: "CA",
    zip: "12345",
    },
    };
    const filter: EventFilter = {
    foo: [{ $exists: false }],
    };

Additional context

Confirmed: the four results above, from running the source on main.

Inference: for retry.fetch, a bodyFilter such as { error: { retryAfter: [{ $exists: false }] } } won't match a body that has no error object at all. That strategy is then skipped. I haven't exercised this through retry.fetch end to end.

Possible fix: when the payload is undefined/null, evaluate the filter against an empty object (or recurse with {}) instead of returning false, so that $exists: false and $isNull-style leaves still get evaluated.

Found with an anti-slop oxlint scan (no-runtime-typeof), then checked by hand.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions