Infinite network request loop due to values in common objects that change on every request

Hi Everyone,

Our resolvers are written to send encrypted value equivalents of the same plaintext values that we can also access in the same GraphQL objects. The purpose for these encrypted values is to hide our database structure. If a client only uses an encrypted value for mutations, then they have no way of guessing other values. Now, we could verify the user with a JWT token and make sure the user can mutate said object, but I have limited authority on this project.

If you haven’t put two and two together from the title, these encrypted values change every time the encrypt function is called. I’ve asked for a ‘time to live’, and it’s under much debate whether such a thing should/could be allowed. Why are these changing values so bad? In Apollo’s normalized cache, two requests that access the same objects, but partial sets of the keys in those objects, go back and forth breaking the cache.

I have tried setting merge: false in the InMemoryCache typePolicies, but this seems to do nothing to prevent this cache from breaking. I tried setting merge: false for individual types in the GraphQL data models as well as creating a custom scalar and doing the same, hoping it would pick it up for every key value in the GraphQL types that is typed as this custom scalar. Disabling the merge function just doesn’t seem to do what I hope it would do no matter how I configure it.

Aside from changing the back end, I’ve found that I can either request the full GraphQL object in every request that involves one of these ever-changing encrypted types, or I can simply remove these calls for the encrypted values in the objects as much as possible and pass these through the component trees. It’s hard for me to predict just how much developer effort it would be to handle either of these work arounds, but since there are about 70 GraphQL types that have the changing encrypted types, and there are 100+ requests and components that currently include them, I assume the effort will be considerable.

There’s another option, which is to totally disable cache, but that’s also pretty unsatisfactory considering the added server load.

I’m asking a couple things…

  1. Is there a way to solve this using the merge function API? The Apollo docs are very sparse in this regard. I realize they weren’t designed to handle this sort of use case, to be fair.

  2. Can anyone with a deep knowledge of the inner workings of Apollo confirm my statements about Apollo’s inherent (and non-disable-able) normalized cache inherently conflicting with our constantly changing values? In other words, this is in fact only truly solvable on the back end.

Versions: we’re on the latest stable release for Apollo client and server.

The only thing I can think of here would be to decrypt these values (I mean, you have to do that to display them anyways, right?) at some point in the link chain, before they reach the cache.

Generally, your observations are correct: we assume that two queries to the same field with the same arguments return the same entity - and they could be merged, but for that, they need a __typename (gets added automatically) and id field to identify if it’s the same or a completely different object.

For normal objects, merge: true would merge them together, even without an id being present, but if they are some kind of string instead of a real object, this seems impossible as well. Either way, yes, the cache will always store them at the same position, assuming that the result adheres to the GraphQL specification and returns an object that can be handled by the cache and merged.

Worst case, I fear there is no real way around disabling the cache in your case.

Out of interest: what do these encrypted responses look like?

The only thing I can think of here would be to decrypt these values (I mean, you have to do that to display them anyways, right?) at some point in the link chain, before they reach the cache.

No, actually we have encrypted and decrypted versions of the same values on the same GraphQL object, and many requests are calling for both of them.

Generally, your observations are correct: we assume that two queries to the same field with the same arguments return the same entity - and they could be merged, but for that, they need a __typename (gets added automatically) and id field to identify if it’s the same or a completely different object.

My assumption is that Apollo doesn’t know which values are dependent on each other, so if any value in the object changes, then it has to totally re-request the object.

We don’t have an id or _id field, but we do have custom keys we define in dataIdFromObject, so the effect is the same.

For normal objects, merge: true would merge them together, even without an id being present, but if they are some kind of string instead of a real object, this seems impossible as well.

We do want to merge the whole object, but we don’t want to merge individual string values on the merge. Is that what is not allowed?

Perhaps, we could just create a library function that spreads all keys besides our encrypted values into the merged object, then use this function to define the merge function for all GraphQL types which have any encrypted values?

I forgot to add — even though the encrypted values constantly change on every request, they don’t actually become invalid. So, you could use an old encrypted value, and it would still work.

I’m sorry, I’d really need to see some explicit examples of what data you’re working with here - I think I’m not understanding enough here to give an actual answer of value :confused:

Ok, let’s get specific…

export type Account = {
  __typename?: 'Account';
  value: Scalars['String'];
  encryptedValue: Scalars['String'];
  <...other values>
}

This is a sketch of my idea:

const mergeWithoutEncrypted = (existing, incoming) => {
  const {
    encryptedValue,
    ...restIncoming
  } = incoming;
  return { ...existing, ...restIncoming };
};

Then,

export const cache = new InMemoryCache({
  typePolicies: {
    Account: { merge: mergeWithoutEncrypted },
  },
});

I just tested this, and it still doesn’t seem to be working. This merge function isn’t even being called, as nothing logs to the console from within the function.

Also, for reference, this is what I attempted previously.

export const cache = new InMemoryCache({
  typePolicies: {
    Account: { 
      fields:{
        encryptedValue: {
          merge: false,
        }
      }
    },
  },
});

I also tried this with the custom scalar type. I preferred this a bit over the previous option because it gave me a single entrypoint to disable all merges for the encrypted fields.

/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  Encrypted: any;
};

export type Account = {
  __typename?: 'Account';
  value: Scalars['String'];
  encryptedValue: Scalars['Encrypted'];
  <...other values>
}

export const cache = new InMemoryCache({
  typePolicies: {
    Encrypted: { 
      merge: false,
    }
  },
});

(I realize I’m not sharing the literal GraphQL schema. I can share that version if this isn’t clear.)

Thanks for you help, by the way.

Now, I’m wondering. Does this custom merge function ONLY work for array types? That is the only example I’ve been able to find, but it doesn’t explicitly say this.

Hmm.
Looking at the code, your

export const cache = new InMemoryCache({
  typePolicies: {
    Account: { merge: mergeWithoutEncrypted },
  },
});

should get called - see this test.

That said, I believe

export const cache = new InMemoryCache({
  typePolicies: {
    Account: { 
      fields:{
        encryptedValue: {
          merge: existing => existing,
        }
      }
    },
  },
});

should do the trick as well.

I’ve now gotten the cache to stop changing with this function, but it still leads to infinite API calls. However, when I simply unify the response body objects (either commenting out the aforementioned changing encrypted values or adding all object keys to all response bodies), this loop stops. Does this make sense to you? Does it decide the cache is broken before it decides how it will merge objects?

“Infinite network requests” sounds very weird, because even in a deadlock situation, AC should stop querying after two requests and just go with one of both. Maybe you have an infinitely remounting component involved that somehow triggers this?