Skip to content

Commit

Permalink
feat: use non-presigned url for S3 HEAD requests (#971)
Browse files Browse the repository at this point in the history
  • Loading branch information
n1ru4l authored Jan 11, 2023
1 parent a57c7be commit 0abc58b
Show file tree
Hide file tree
Showing 12 changed files with 773 additions and 128 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-moose-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/client': minor
---

Retry failed requests upon CDN issues.
74 changes: 48 additions & 26 deletions packages/libraries/client/src/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,58 @@ export function createSupergraphSDLFetcher({ endpoint, key }: SupergraphSDLFetch
headers['If-None-Match'] = cacheETag;
}

return axios
.get(endpoint + '/supergraph', {
headers,
})
.then(response => {
if (response.status >= 200 && response.status < 300) {
const supergraphSdl = response.data;
const result = {
id: createHash('sha256').update(supergraphSdl).digest('base64'),
supergraphSdl,
};

const etag = response.headers['etag'];
if (etag) {
cached = result;
cacheETag = etag;
let retryCount = 0;

const retry = (status: number) => {
if (retryCount >= 10 || status < 499) {
return Promise.reject(new Error(`Failed to fetch [${status}]`));
}

retryCount = retryCount + 1;

return fetchWithRetry();
};

const fetchWithRetry = (): Promise<{ id: string; supergraphSdl: string }> => {
return axios
.get(endpoint + '/supergraph', {
headers,
})
.then(response => {
if (response.status >= 200 && response.status < 300) {
const supergraphSdl = response.data;
const result = {
id: createHash('sha256').update(supergraphSdl).digest('base64'),
supergraphSdl,
};

const etag = response.headers['etag'];
if (etag) {
cached = result;
cacheETag = etag;
}

return result;
}

return result;
}
return retry(response.status);
})
.catch(async error => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 304 && cached !== null) {
return cached;
}

return Promise.reject(new Error(`Failed to fetch supergraph [${response.status}]`));
})
.catch(async error => {
if (axios.isAxiosError(error) && error.response?.status === 304 && cached !== null) {
return cached;
}
if (error.response?.status) {
return retry(error.response.status);
}
}

throw error;
});
throw error;
});
};

return fetchWithRetry();
};
}

Expand Down
132 changes: 87 additions & 45 deletions packages/libraries/client/src/gateways.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ interface Schema {
name: string;
}

function createFetcher<T>({ endpoint, key }: SchemaFetcherOptions & ServicesFetcherOptions) {
function createFetcher({ endpoint, key }: SchemaFetcherOptions & ServicesFetcherOptions) {
let cacheETag: string | null = null;
let cached: {
id: string;
supergraphSdl: string;
} | null = null;

return function fetcher(): Promise<T> {
return function fetcher(): Promise<readonly Schema[] | Schema> {
const headers: {
[key: string]: string;
} = {
Expand All @@ -29,64 +29,106 @@ function createFetcher<T>({ endpoint, key }: SchemaFetcherOptions & ServicesFetc
headers['If-None-Match'] = cacheETag;
}

return axios
.get(endpoint + '/services', {
headers,
responseType: 'json',
})
.then(response => {
if (response.status >= 200 && response.status < 300) {
const result = response.data;

const etag = response.headers['etag'];
if (etag) {
cached = result;
cacheETag = etag;
let retryCount = 0;

const retry = (status: number) => {
if (retryCount >= 10 || status < 499) {
return Promise.reject(new Error(`Failed to fetch [${status}]`));
}

retryCount = retryCount + 1;

return fetchWithRetry();
};

const fetchWithRetry = (): Promise<readonly Schema[] | Schema> => {
return axios
.get(endpoint + '/services', {
headers,
responseType: 'json',
})
.then(response => {
if (response.status >= 200 && response.status < 300) {
const result = response.data;

const etag = response.headers['etag'];
if (etag) {
cached = result;
cacheETag = etag;
}

return result;
}

return result;
}
return retry(response.status);
})
.catch(async error => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 304 && cached !== null) {
return cached;
}

return Promise.reject(new Error(`Failed to fetch [${response.status}]`));
})
.catch(async error => {
if (axios.isAxiosError(error) && error.response?.status === 304 && cached !== null) {
return cached;
}
if (error.response?.status) {
return retry(error.response.status);
}
}

throw error;
});
};

throw error;
});
return fetchWithRetry();
};
}

export function createSchemaFetcher({ endpoint, key }: SchemaFetcherOptions) {
const fetcher = createFetcher<Schema>({ endpoint, key });
const fetcher = createFetcher({ endpoint, key });

return function schemaFetcher() {
return fetcher().then(schema => ({
id: createHash('sha256')
.update(schema.sdl)
.update(schema.url || '')
.update(schema.name)
.digest('base64'),
...schema,
}));
return fetcher().then(schema => {
let service: Schema;
// Before the new artifacts endpoint the body returned an array or a single object depending on the project type.
// This handles both in a backwards-compatible way.
if (schema instanceof Array) {
if (schema.length !== 1) {
throw new Error(
'Encountered multiple services instead of a single service. Please use createServicesFetcher instead.',
);
}
service = schema[0];
} else {
service = schema;
}

return {
id: createSchemaId(service),
...service,
};
});
};
}

export function createServicesFetcher({ endpoint, key }: ServicesFetcherOptions) {
const fetcher = createFetcher<readonly Schema[]>({ endpoint, key });
const fetcher = createFetcher({ endpoint, key });

return function schemaFetcher() {
return fetcher().then(services =>
services.map(service => ({
id: createHash('sha256')
.update(service.sdl)
.update(service.url || '')
.update(service.name)
.digest('base64'),
...service,
})),
);
return fetcher().then(services => {
if (services instanceof Array) {
return services.map(service => ({
id: createSchemaId(service),
...service,
}));
}
throw new Error(
'Encountered a single service instead of a multiple services. Please use createSchemaFetcher instead.',
);
});
};
}

const createSchemaId = (service: Schema) =>
createHash('sha256')
.update(service.sdl)
.update(service.url || '')
.update(service.name)
.digest('base64');
52 changes: 52 additions & 0 deletions packages/libraries/client/tests/apollo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,55 @@ test('createSupergraphSDLFetcher', async () => {
expect(staleResult.id).toBeDefined();
expect(staleResult.supergraphSdl).toEqual(newSupergraphSdl);
});

test('createSupergraphSDLFetcher retry with unexpected status code (nRetryCount=10)', async () => {
const supergraphSdl = 'type SuperQuery { sdl: String }';
const key = 'secret-key';
nock('http://localhost')
.get('/supergraph')
.times(10)
.reply(500)
.get('/supergraph')
.once()
.matchHeader('X-Hive-CDN-Key', key)
.reply(200, supergraphSdl, {
ETag: 'first',
});

const fetcher = createSupergraphSDLFetcher({
endpoint: 'http://localhost',
key,
});

const result = await fetcher();

expect(result.id).toBeDefined();
expect(result.supergraphSdl).toEqual(supergraphSdl);
});

test('createSupergraphSDLFetcher retry with unexpected status code (nRetryCount=11)', async () => {
expect.assertions(1);
const supergraphSdl = 'type SuperQuery { sdl: String }';
const key = 'secret-key';
nock('http://localhost')
.get('/supergraph')
.times(11)
.reply(500)
.get('/supergraph')
.once()
.matchHeader('X-Hive-CDN-Key', key)
.reply(200, supergraphSdl, {
ETag: 'first',
});

const fetcher = createSupergraphSDLFetcher({
endpoint: 'http://localhost',
key,
});

try {
await fetcher();
} catch (err) {
expect(err).toMatchInlineSnapshot(`[Error: Failed to fetch [500]]`);
}
});
71 changes: 71 additions & 0 deletions packages/libraries/client/tests/gateways.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,74 @@ test('createSchemaFetcher with ETag', async () => {
expect(staleResult.sdl).toEqual(newSchema.sdl);
expect(staleResult.url).toEqual(newSchema.url);
});

test('retry in case of unexpected CDN status code (nRetryCount=10)', async () => {
const schema = {
sdl: 'type Query { noop: String }',
url: 'service-url',
name: 'service-name',
};

const key = 'secret-key';

nock('http://localhost')
.get('/services')
.times(10)
.matchHeader('X-Hive-CDN-Key', key)
.matchHeader('accept', 'application/json')
.reply(500)
.get('/services')
.once()
.matchHeader('X-Hive-CDN-Key', key)
.matchHeader('accept', 'application/json')
.reply(200, schema, {
ETag: 'first',
});

const fetcher = createSchemaFetcher({
endpoint: 'http://localhost',
key,
});

const result = await fetcher();
expect(result.id).toBeDefined();
expect(result.name).toEqual(result.name);
expect(result.sdl).toEqual(result.sdl);
expect(result.url).toEqual(result.url);
});

test('fail in case of unexpected CDN status code (nRetryCount=11)', async () => {
expect.assertions(1);
const schema = {
sdl: 'type Query { noop: String }',
url: 'service-url',
name: 'service-name',
};

const key = 'secret-key';

nock('http://localhost')
.get('/services')
.times(11)
.matchHeader('X-Hive-CDN-Key', key)
.matchHeader('accept', 'application/json')
.reply(500)
.get('/services')
.once()
.matchHeader('X-Hive-CDN-Key', key)
.matchHeader('accept', 'application/json')
.reply(200, schema, {
ETag: 'first',
});

const fetcher = createSchemaFetcher({
endpoint: 'http://localhost',
key,
});

try {
await fetcher();
} catch (e) {
expect(e).toMatchInlineSnapshot(`[Error: Failed to fetch [500]]`);
}
});
Loading

0 comments on commit 0abc58b

Please sign in to comment.