-
Notifications
You must be signed in to change notification settings - Fork 265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Breaking change] createWithCache
v2
#2546
base: main
Are you sure you want to change the base?
Conversation
We detected some changes in |
Oxygen deployed a preview of your
Learn more about Hydrogen's GitHub integration. |
/** | ||
* Fetches data from a URL and caches the result according to the strategy provided. | ||
* When the response is not successful (e.g. status code >= 400), the caching is | ||
* skipped automatically and the returned `data` is `null`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not return data even when the status isn't ok?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was doing this at first but I'm not sure it's ideal. The thing is, if the status is not OK, the returned body might be an error message, a whole HTML page with instructions, or something completely random (depends on the API).
The dev might not even know the shape.
If we read the body in this situation, we don't know about the type returned in data
. That's why I thought returning null
and letting them choose what to do with the response.body might be better.
Alternatively, perhaps we could return string
type in this scenario? So if you do fetch<MyData>(...)
you get back data: MyData | string
, depending on the type. We could allow passing as second generic for this type as well 🤔
Or we could add a different returned parameter: const {data, response, errorBody} = fetch()
, where errorBody
is only returned on non-200 responses? 🫤
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing I noticed is that it's not possible to narrow the returned type based on nested properties. For example response.ok === false
should mean that data: null
but the narrowing doesn't work because ok
is nested in response
.
Considering this, perhaps we should return something that mimics a response instead:
const response = await fetch<MyData, MyError>(...);
// This supports type narrowing:
if (!response.ok) {
// response.body is type MyError (defaults to 'string')
console.log(response.body)
return;
}
// response.body is type MyData
return response.body;
This response
is not a real Fetch Response type, just something that contains the useful data from it: consumed body, ok: boolean
, status: number
, statusText: string
, headers: Headers
, etc.
Would that be better, or more confusing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just think that there sometimes might be logic even when something fails. And it is common for there to be a failure code in the response. But like you said, we have no idea what it could be. Sometimes it won't even be a string if the 500 has no body. Perhaps we just mark it as unknown
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just think that there sometimes might be logic even when something fails
Yes yes, I agree. I guess we are just discussing how we get that payload after errors.
Sometimes it won't even be a string if the 500 has no body
In this case we can return empty string, which is not incorrect I guess (if we go with data: string | MyData
)
Perhaps we just mark it as unknown?
In this case, data: unknown | MyData
turns always into unknown
.
The only way to make it work would be using a different property to return the error body (as mentioned here) or by returning a top level Response-like payload (mentioned here).
Which one would you like more?
cc @wizardlyhel thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What would be simplest? We could just serialize it to string. And it's up to them to parse it? So it's always present, a string of the body response, and an empty string if there is no body.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are allowing devs to pass in their own type, wouldn't they know what to expect the shape of the data even if the response is not ok.
So technically, in our code, we just need data: T
and devs should supply cache.fetch<MyData | MyErrorData | null>(...)
to anticipate the shape of the returned data, even when it errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's pretty common for the error type to be different than the 200 type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would it make sense to return response.clone().body
for data?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wouldn't they know what to expect the shape of the data even if the response is not ok.
I wouldn't have this expectation. Too many API have undocumented responses / behavior specially when approaching rate limits / high-traffic etc
cache?: CachingStrategy; | ||
cacheInstance?: Cache; | ||
cacheKey?: CacheKey; | ||
shouldCacheResponse?: (body: any, response: Response) => boolean; | ||
shouldCacheResponse?: (body: T, response: Response) => boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we want to make this function as a required field
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's an open question I wrote in the OP:
fetch already checks for status errors (only caches 2xx responses) but there could still be situations where devs don't want to cache the result based on body (e.g. GraphQL APIs tend to return errors in body with a 200 status). For this, you can pass shouldCacheResponse parameter optionally. Do we want to make this a required param instead to force devs check the integrity of the response body?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for required
): Promise<{data: Body | null; response: Response}> { | ||
return fetchWithServerCache<Body | null>(url, requestInit ?? {}, { | ||
waitUntil, | ||
cacheKey: [url, requestInit], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this requestInit
might end up as [Object object]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It gets stringified later in hashKey
so I think we're good. That said, maybe we should flatten it here for a small perf.
Also, perhaps we should ensure headers are turned into entries here. Should we consider filtering headers automatically? Or leave that to the developer? They can always provide a manual cache key.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps we should ensure headers are turned into entries here
I think that having requestInit
is something that shouldn't be use as a default cache key. A requestInit
contains the following:
body
- this could be anything but since it's the body of a request, it should be minimalheaders
- this is the worrying piece. What if this contains some header that shouldn't be used for as cache key? For example, in our own storefront handler, we manually took out some headers, that contains cookie information that we had to pass along as headers in the request, for creating a cache key.
I would prefer the cacheKey be defaulted to just [url, requestInit.body]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good callout Helen. I think the main issue would be if the user just proxied the primary request headers to the sub-request headers, then they'd definitely have few cache hits. But I don't think that would be common. If I'm using the API, and I write this code, I don't think I'd expect them to share the same cache:
cache.fetch('some-api.json', {
method: 'GET',
headers: { token: env.someApiToken }
});
// vs
cache.fetch('some-api.json', {
method: 'GET',
headers: { token: env.someOtherApiToken }
});
A common use case here would be calling a 3p API for personalized results. If a 3p API uses headers to define the persona of the request, then the cache would bleed between users.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mmm ... if we are debating over what is safer between never hit a cache read or cache bleed, I would say never hit a cache read is safer.
Then I'm fine with spreading the requestInit and the header as part of the cache key
let data; | ||
if (!response.ok) { | ||
// Skip caching and consuming the response body | ||
return response; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this follow the return type of [T, Response]
?
Sorry finally coming around to reviewing this
I would prefer
Although more verbose I would prefer to force devs in providing
I also prefer object. Maybe
Yeah this is great. |
@rbshop and I just went thru this PR and addressed some feedbacks. Usage update: Converted from inline parameters to a single object - making - withCache(cacheKey, strategy, actionFn)
+ withCache.run({
+ cacheKey,
+ strategy,
+ shouldCacheResult,
+ actionFn,
+ }) The naming of each parameter are still pending. New addition of withCache.fetch(
'https://www.google.com', // url
{}, // requestInit
{ // options
cacheKey: ['google']
cache: CacheLong(),
shouldCacheResult: () => true,
}
).then(({data, response}) => {
console.log(data);
}) However these parameter will be named, they should be the same names that's used in |
Proposal for a new version of
createWithCache
with intentional breaking changes.The current version returns a function that runs any asynchronous operation and you are on charge of throwing to prevent caching errors. This seems to be easy to forget and devs are often caching errors by mistake.
Since most of the times this function seem to be used to call a single
fetch
operation, we can add a new utility to do this but where we do the status check on their behalf:Questions:
fetch
for the cache options, or a custom property in the second param? E.g.withCache.fetch('...', {headers, body, hydrogen: { cache: CacheLong() })
fetch
already checks for status errors (only caches 2xx responses) but there could still be situations where devs don't want to cache the result based on body (e.g. GraphQL APIs tend to returnerrors
in body with a 200 status). For this, you can passshouldCacheResponse
parameter optionally. Do we want to make this a required param instead to force devs check the integrity of the response body?{data, response}
with named properties, or prefer array like[data, response]
for easier renaming? I kind of prefer the object but it's a bit verbose when the response body contains anotherdata
:const { data: { data, errors }, response } = withCache.fetch(...)
.data
will benull
. This way devs canresponse.text()
if their API returns extra info if they want.While we could avoid breaking changes by nesting
fetch
under the run function, or creating a complete new utility, I think it's useful to add a breaking change in this case to force devs misusingrun
to switch tofetch
. Also, the migration for those who want to stick torun
would just be replacingconst withCache = createWithCache(...)
withconst {run: withCache} = createWithCache(...)
inserver.ts
, so it's not too problematic.