@financial-times/cp-content-pipeline-schema 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/datasources/capi.d.ts +1 -1
  3. package/lib/datasources/capi.js +14 -39
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/instrumented.d.ts +4 -1
  6. package/lib/datasources/instrumented.js +16 -16
  7. package/lib/datasources/instrumented.js.map +1 -1
  8. package/lib/datasources/origami-image.d.ts +1 -1
  9. package/lib/datasources/origami-image.js +7 -21
  10. package/lib/datasources/origami-image.js.map +1 -1
  11. package/lib/datasources/twitter.d.ts +1 -1
  12. package/lib/datasources/twitter.js +7 -21
  13. package/lib/datasources/twitter.js.map +1 -1
  14. package/lib/model/CapiResponse.js +33 -4
  15. package/lib/model/CapiResponse.js.map +1 -1
  16. package/lib/model/Concept.js +1 -1
  17. package/lib/model/Concept.js.map +1 -1
  18. package/lib/model/Image.js +8 -3
  19. package/lib/model/Image.js.map +1 -1
  20. package/lib/model/Person.js +7 -2
  21. package/lib/model/Person.js.map +1 -1
  22. package/lib/model/Topper.js +7 -6
  23. package/lib/model/Topper.js.map +1 -1
  24. package/lib/model/Topper.test.js +1 -1
  25. package/lib/model/Topper.test.js.map +1 -1
  26. package/lib/resolvers/content-tree/bodyXMLToTree.js +1 -1
  27. package/lib/resolvers/content-tree/bodyXMLToTree.js.map +1 -1
  28. package/lib/resolvers/content-tree/bodyXMLToTree.test.js +7 -7
  29. package/lib/resolvers/content-tree/bodyXMLToTree.test.js.map +1 -1
  30. package/lib/resolvers/content-tree/references/Flourish.js +7 -2
  31. package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
  32. package/lib/resolvers/content-tree/references/RawImage.js +7 -2
  33. package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
  34. package/lib/resolvers/content-tree/references/Recommended.js +1 -1
  35. package/lib/resolvers/content-tree/references/Recommended.js.map +1 -1
  36. package/lib/resolvers/content-tree/references/Tweet.js +7 -2
  37. package/lib/resolvers/content-tree/references/Tweet.js.map +1 -1
  38. package/lib/resolvers/content-tree/references/Video.js +15 -2
  39. package/lib/resolvers/content-tree/references/Video.js.map +1 -1
  40. package/lib/resolvers/core.js +16 -1
  41. package/lib/resolvers/core.js.map +1 -1
  42. package/package.json +5 -2
  43. package/src/datasources/capi.ts +16 -44
  44. package/src/datasources/instrumented.ts +29 -31
  45. package/src/datasources/origami-image.ts +11 -25
  46. package/src/datasources/twitter.ts +10 -24
  47. package/src/model/CapiResponse.ts +44 -6
  48. package/src/model/Concept.ts +1 -1
  49. package/src/model/Image.ts +9 -4
  50. package/src/model/Person.ts +7 -2
  51. package/src/model/Topper.test.ts +2 -2
  52. package/src/model/Topper.ts +10 -5
  53. package/src/resolvers/content-tree/bodyXMLToTree.test.ts +7 -7
  54. package/src/resolvers/content-tree/bodyXMLToTree.ts +1 -1
  55. package/src/resolvers/content-tree/references/Flourish.ts +7 -2
  56. package/src/resolvers/content-tree/references/RawImage.ts +7 -2
  57. package/src/resolvers/content-tree/references/Recommended.ts +1 -1
  58. package/src/resolvers/content-tree/references/Tweet.ts +7 -2
  59. package/src/resolvers/content-tree/references/Video.ts +18 -4
  60. package/src/resolvers/core.ts +18 -1
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -6,19 +6,22 @@ import {
6
6
  AugmentedRequest,
7
7
  } from '@apollo/datasource-rest'
8
8
  import { PrefixingKeyValueCache } from '@apollo/utils.keyvaluecache'
9
- import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
9
+ import { createFetchErrorHandler } from '@dotcom-reliability-kit/fetch-error-handler'
10
+ import type { FetchErrorHandler } from '@dotcom-reliability-kit/fetch-error-handler/lib/create-handler'
10
11
  import { QueryContext } from '..'
11
12
  import { isContextableCache } from '../types/cache'
12
13
  import { BaseDataSource, BaseDataSourceOptions } from './base'
14
+ import { BaseError } from '@dotcom-reliability-kit/errors'
13
15
 
14
16
  export class InstrumentedRESTDataSource
15
17
  extends RESTDataSource
16
18
  implements BaseDataSource
17
19
  {
18
20
  startTime?: bigint
19
- backendSystemCodes: string[] = []
21
+ backendSystemCode: string | undefined
20
22
  context: QueryContext
21
23
  calls: string[] = []
24
+ errorHandler: FetchErrorHandler
22
25
 
23
26
  constructor({ cache, context }: BaseDataSourceOptions) {
24
27
  const wrappedCache = new PrefixingKeyValueCache(
@@ -28,6 +31,11 @@ export class InstrumentedRESTDataSource
28
31
 
29
32
  super({
30
33
  cache: wrappedCache,
34
+ fetch: (url, init) => this.errorHandler(global.fetch(url, init)),
35
+ })
36
+
37
+ this.errorHandler = createFetchErrorHandler({
38
+ upstreamSystemCode: this.backendSystemCode,
31
39
  })
32
40
 
33
41
  // okay _now_ we can use `this`
@@ -59,6 +67,18 @@ export class InstrumentedRESTDataSource
59
67
  // eslint-disable-next-line @typescript-eslint/no-empty-function
60
68
  async throwIfResponseIsError() {}
61
69
 
70
+ logResponseMetrics(status: number, duration: bigint): void {
71
+ this.context.metrics?.count(
72
+ `graphql.datasource.${this.constructor.name}.response.${status}.count`,
73
+ 1
74
+ )
75
+
76
+ this.context.metrics?.count(
77
+ `graphql.datasource.${this.constructor.name}.response.${status}.time`,
78
+ Number(duration)
79
+ )
80
+ }
81
+
62
82
  async fetch<TResult>(
63
83
  path: string,
64
84
  incomingRequest?: DataSourceRequest<CacheOptions>
@@ -74,40 +94,18 @@ export class InstrumentedRESTDataSource
74
94
  const result = await super.fetch<TResult>(path, incomingRequest)
75
95
  const duration = (process.hrtime.bigint() - startTime) / BigInt(1e6)
76
96
 
77
- this.context.metrics?.count(
78
- `graphql.datasource.${this.constructor.name}.response.${result.response.status}.count`,
79
- 1
80
- )
81
-
82
- this.context.metrics?.count(
83
- `graphql.datasource.${this.constructor.name}.response.${result.response.status}.time`,
84
- Number(duration)
85
- )
86
-
87
- if (!result.response.ok) {
88
- throw new UpstreamServiceError({
89
- message: `${result.response.status}: ${result.response.statusText} from ${this.constructor.name}`,
90
- statusCode: result.response.status,
91
- relatesToSystems: this.backendSystemCodes,
92
- url: result.response.url,
93
- body: result.parsedBody,
94
- })
95
- }
97
+ this.logResponseMetrics(result.response.status, duration)
96
98
 
97
99
  return result
98
100
  } catch (error) {
99
- if (error instanceof Error && error.name === 'AbortError') {
101
+ if (error instanceof BaseError) {
102
+ const status =
103
+ error.code === 'FETCH_ABORT_ERROR'
104
+ ? 408
105
+ : Number(error.data.upstreamStatusCode ?? 0)
100
106
  const duration = (process.hrtime.bigint() - startTime) / BigInt(1e6)
101
107
 
102
- this.context.metrics?.count(
103
- `graphql.datasource.${this.constructor.name}.response.408.count`,
104
- 1
105
- )
106
-
107
- this.context.metrics?.count(
108
- `graphql.datasource.${this.constructor.name}.response.408.time`,
109
- Number(duration)
110
- )
108
+ this.logResponseMetrics(status, duration)
111
109
  }
112
110
 
113
111
  throw error
@@ -1,4 +1,3 @@
1
- import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
2
1
  import { InstrumentedRESTDataSource } from './instrumented'
3
2
  import { AugmentedRequest, CacheOptions } from '@apollo/datasource-rest'
4
3
 
@@ -6,7 +5,7 @@ const REQUEST_TIMEOUT = 5000 // 5 seconds
6
5
 
7
6
  export class OrigamiImageDataSource extends InstrumentedRESTDataSource {
8
7
  baseURL = 'https://www.ft.com/__origami/service/image/v2/'
9
- backendSystemCodes = ['origami-image-service-v2']
8
+ backendSystemCode = 'origami-image-service-v2'
10
9
 
11
10
  abortController = new AbortController()
12
11
  timeout: ReturnType<typeof setTimeout> | undefined = undefined
@@ -32,29 +31,16 @@ export class OrigamiImageDataSource extends InstrumentedRESTDataSource {
32
31
  async getImageMetadata(
33
32
  url: string
34
33
  ): Promise<{ width: number; height: number }> {
35
- try {
36
- const imageMetadata = await this.get(
37
- `images/metadata/${encodeURIComponent(url)}?source=next`
38
- )
39
-
40
- this.calls.push(url)
41
-
42
- if (this.timeout) {
43
- clearTimeout(this.timeout)
44
- this.timeout = undefined
45
- }
46
- return imageMetadata
47
- } catch (error) {
48
- if (error instanceof Error && error.name === 'AbortError') {
49
- throw new UpstreamServiceError({
50
- code: 'ORIGAMI_DATASOURCE_TIMEOUT',
51
- message: `Request to Image Service took longer than ${REQUEST_TIMEOUT}ms, and so has been aborted.`,
52
- statusCode: 408,
53
- relatesToSystems: this.backendSystemCodes,
54
- })
55
- }
56
-
57
- throw error
34
+ const imageMetadata = await this.get(
35
+ `images/metadata/${encodeURIComponent(url)}?source=next`
36
+ )
37
+
38
+ this.calls.push(url)
39
+
40
+ if (this.timeout) {
41
+ clearTimeout(this.timeout)
42
+ this.timeout = undefined
58
43
  }
44
+ return imageMetadata
59
45
  }
60
46
  }
@@ -1,11 +1,10 @@
1
1
  import { AugmentedRequest, CacheOptions } from '@apollo/datasource-rest'
2
- import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
3
2
  import { InstrumentedRESTDataSource } from './instrumented'
4
3
 
5
4
  const REQUEST_TIMEOUT = 5000 // 5 seconds
6
5
  export class TwitterDataSource extends InstrumentedRESTDataSource {
7
6
  baseURL = 'https://publish.twitter.com'
8
- backendSystemCodes = ['twitter-oembed-api']
7
+ backendSystemCode = 'twitter-oembed-api'
9
8
 
10
9
  abortController = new AbortController()
11
10
  timeout: ReturnType<typeof setTimeout> | undefined = undefined
@@ -21,29 +20,16 @@ export class TwitterDataSource extends InstrumentedRESTDataSource {
21
20
  }
22
21
 
23
22
  async getTweet(tweetUrl: string) {
24
- try {
25
- const tweet = await this.get(`oembed?url=${tweetUrl}&omit_script=true`)
26
-
27
- this.calls.push(tweetUrl)
28
-
29
- if (this.timeout) {
30
- clearTimeout(this.timeout)
31
- this.timeout = undefined
32
- }
33
-
34
- return tweet
35
- } catch (error) {
36
- if (error instanceof Error && error.name === 'AbortError') {
37
- throw new UpstreamServiceError({
38
- code: 'TWITTER_DATASOURCE_TIMEOUT',
39
- message: `Request to Twitter API took longer than ${REQUEST_TIMEOUT}ms, and so has been aborted.`,
40
- statusCode: 408,
41
- relatesToSystems: this.backendSystemCodes,
42
- })
43
- }
44
-
45
- throw error
23
+ const tweet = await this.get(`oembed?url=${tweetUrl}&omit_script=true`)
24
+
25
+ this.calls.push(tweetUrl)
26
+
27
+ if (this.timeout) {
28
+ clearTimeout(this.timeout)
29
+ this.timeout = undefined
46
30
  }
31
+
32
+ return tweet
47
33
  }
48
34
 
49
35
  cacheOptionsFor(): CacheOptions {
@@ -188,7 +188,7 @@ export class CapiResponse {
188
188
  * Don't let this failure block this system from continuing to try and handle the request
189
189
  */
190
190
  if (!schemaResponse.success) {
191
- context.logger.error({
191
+ context.logger.warn({
192
192
  event: 'RECOVERABLE_ERROR',
193
193
  error: new OperationalError({
194
194
  message:
@@ -286,7 +286,7 @@ export class CapiResponse {
286
286
  return vanityUrl ?? url
287
287
  } catch (error) {
288
288
  if (isError(error)) {
289
- this.context.logger.error({
289
+ this.context.logger.warn({
290
290
  event: 'RECOVERABLE_ERROR',
291
291
  error,
292
292
  })
@@ -405,9 +405,24 @@ export class CapiResponse {
405
405
  'containedIn' in this.capiData && this.capiData.containedIn?.[0]?.id
406
406
 
407
407
  if (containerId) {
408
- container = await this.context.dataSources.capi.getContent(
409
- uuidFromUrl(containerId)
410
- )
408
+ try {
409
+ container = await this.context.dataSources.capi.getContent(
410
+ uuidFromUrl(containerId)
411
+ )
412
+ } catch (error) {
413
+ if (error instanceof Error) {
414
+ this.context.logger.warn({
415
+ event: 'RECOVERABLE_ERROR',
416
+ error: new OperationalError({
417
+ message: `Failed to fetch package container ${containerId} for content ${this.id()}`,
418
+ code: `PACKAGE_CONTAINEDIN_ERROR`,
419
+ cause: error,
420
+ }),
421
+ })
422
+ }
423
+
424
+ return null
425
+ }
411
426
  this.packageContainer = container
412
427
  }
413
428
  }
@@ -652,11 +667,34 @@ export class CapiResponse {
652
667
 
653
668
  this.context.addSurrogateKeys(contains.map((article) => article.id))
654
669
 
655
- return Promise.all(
670
+ const results = await Promise.allSettled(
656
671
  contains.map(({ id }) =>
657
672
  this.context.dataSources.capi.getContent(uuidFromUrl(id), this)
658
673
  )
659
674
  )
675
+
676
+ const failed = results.filter(
677
+ (result): result is PromiseRejectedResult =>
678
+ result.status === 'rejected'
679
+ )
680
+
681
+ if (failed.length) {
682
+ this.context.logger.warn({
683
+ event: 'RECOVERABLE_ERROR',
684
+ error: new OperationalError({
685
+ code: 'PACKAGE_CONTAINS_ERROR',
686
+ message: `Failed to fetch some of the content contained in the package ${this.id()}`,
687
+ cause: new AggregateError(failed.map((result) => result.reason)),
688
+ }),
689
+ })
690
+ }
691
+
692
+ return results
693
+ .filter(
694
+ (result): result is PromiseFulfilledResult<CapiResponse> =>
695
+ result.status === 'fulfilled'
696
+ )
697
+ .map((result) => result.value)
660
698
  }
661
699
 
662
700
  return null
@@ -154,7 +154,7 @@ export class Concept {
154
154
  }
155
155
  } catch (error) {
156
156
  if (isError(error)) {
157
- this.context.logger.error({
157
+ this.context.logger.warn({
158
158
  event: 'RECOVERABLE_ERROR',
159
159
  error,
160
160
  })
@@ -12,7 +12,7 @@ import {
12
12
  } from '../resolvers/literal-union'
13
13
  import { ImageFormat, ImageType } from '../resolvers/scalars'
14
14
  import isError from '../helpers/isError'
15
- import { BaseError } from '@dotcom-reliability-kit/errors'
15
+ import { BaseError, OperationalError } from '@dotcom-reliability-kit/errors'
16
16
  import type { ImageSource } from '../generated'
17
17
 
18
18
  export type ImageSourceArgs = {
@@ -54,7 +54,7 @@ export class CAPIImage implements Image {
54
54
  }
55
55
 
56
56
  // we shouldn't be here. but just in case,
57
- this.context.logger.error({
57
+ this.context.logger.warn({
58
58
  event: 'RECOVERABLE_ERROR',
59
59
  error: new BaseError({
60
60
  code: 'INVALID_IMAGE_TYPE',
@@ -164,9 +164,14 @@ export class CAPIImage implements Image {
164
164
  return imageMetadata
165
165
  } catch (error) {
166
166
  if (isError(error)) {
167
- this.context.logger.error({
167
+ this.context.logger.warn({
168
168
  event: 'RECOVERABLE_ERROR',
169
- error,
169
+ error: new OperationalError({
170
+ code: 'IMAGE_DIMENSIONS_ERROR',
171
+ message: `Failed to get dimensions for ${this.capiImage.binaryUrl}`,
172
+ url: this.capiImage.binaryUrl,
173
+ cause: error,
174
+ }),
170
175
  })
171
176
  }
172
177
  return null
@@ -4,6 +4,7 @@ import { CapiResponse } from './CapiResponse'
4
4
  import imageServiceUrl from '../helpers/imageService'
5
5
  import isError from '../helpers/isError'
6
6
  import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
7
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
7
8
 
8
9
  type HeadshotArguments = {
9
10
  width?: number | null
@@ -63,9 +64,13 @@ export class Person {
63
64
  : null
64
65
  } catch (error) {
65
66
  if (isError(error)) {
66
- this.context.logger.error({
67
+ this.context.logger.warn({
67
68
  event: 'RECOVERABLE_ERROR',
68
- error,
69
+ error: new OperationalError({
70
+ message: `Error fetching CAPI Person for headshot URL for ${this.prefLabel()}`,
71
+ code: 'HEADSHOT_PERSON_FETCH_ERROR',
72
+ cause: error,
73
+ }),
69
74
  })
70
75
  }
71
76
  return null
@@ -2,7 +2,7 @@ import { Topper } from './Topper'
2
2
  import { Person } from './Person'
3
3
  import { baseCapiObject } from '../fixtures/capiObject'
4
4
  import { CapiResponse } from './CapiResponse'
5
- import { predicates } from './Concept'
5
+ import { Concept, predicates } from './Concept'
6
6
  import cloneDeep from 'clone-deep'
7
7
  import conceptIds from '@financial-times/n-concept-ids'
8
8
 
@@ -454,7 +454,7 @@ describe('headshot method', () => {
454
454
  it('attempts to return the author headshot if the content is opinion genre', async () => {
455
455
  jest.spyOn(capiResponse, 'isPodcast').mockReturnValue(false)
456
456
  jest.spyOn(capiResponse, 'isOpinion').mockReturnValue(true)
457
- jest.spyOn(capiResponse, 'getAuthors').mockReturnValue([])
457
+ jest.spyOn(capiResponse, 'getAuthors').mockReturnValue([{} as Concept])
458
458
  jest
459
459
  .spyOn(Person.prototype, 'headshot')
460
460
  .mockReturnValue(Promise.resolve(null))
@@ -1,5 +1,4 @@
1
1
  import { CapiResponse } from './CapiResponse'
2
- import { Person } from './Person'
3
2
  import { CAPIImage } from './Image'
4
3
  import type { QueryContext } from '..'
5
4
  import {
@@ -316,9 +315,15 @@ export class Topper {
316
315
  args.url = this.capiResponse.mainImage()?.url()
317
316
  return this.imageService(args)
318
317
  }
319
- if (!this.capiResponse.isOpinion()) return null
320
- const author = this.capiResponse.getAuthors()[0]
321
- const person = new Person(author, this.capiResponse, this.context)
322
- return person.headshot(args)
318
+
319
+ if (this.capiResponse.isOpinion()) {
320
+ const authors = this.capiResponse.authors()
321
+
322
+ if (authors && authors.length >= 1) {
323
+ return authors[0].headshot(args)
324
+ }
325
+ }
326
+
327
+ return null
323
328
  }
324
329
  }
@@ -6,7 +6,7 @@ import { QueryContext, BodyXMLToTreeError } from '../..'
6
6
  import { OperationalError } from '@dotcom-reliability-kit/errors'
7
7
 
8
8
  const mockLogger = new Logger()
9
- const mockLogError = jest.spyOn(mockLogger, 'error')
9
+ const mockLogWarn = jest.spyOn(mockLogger, 'warn')
10
10
 
11
11
  const mockContext = {
12
12
  logger: mockLogger,
@@ -233,7 +233,7 @@ describe('bodyXMLToTree', () => {
233
233
  "version": 1,
234
234
  }
235
235
  `)
236
- expect(mockLogError).not.toBeCalled()
236
+ expect(mockLogWarn).not.toBeCalled()
237
237
  })
238
238
 
239
239
  it('should handle heading and slots', () => {
@@ -268,7 +268,7 @@ describe('bodyXMLToTree', () => {
268
268
  "version": 1,
269
269
  }
270
270
  `)
271
- expect(mockLogError).not.toBeCalled()
271
+ expect(mockLogWarn).not.toBeCalled()
272
272
  })
273
273
 
274
274
  it('should log an error on unexpected child after heading', () => {
@@ -303,8 +303,8 @@ describe('bodyXMLToTree', () => {
303
303
  "version": 1,
304
304
  }
305
305
  `)
306
- expect(mockLogError).toBeCalled()
307
- expect(mockLogError.mock.lastCall).toMatchInlineSnapshot(`
306
+ expect(mockLogWarn).toBeCalled()
307
+ expect(mockLogWarn.mock.lastCall).toMatchInlineSnapshot(`
308
308
  Array [
309
309
  Object {
310
310
  "error": Object {
@@ -349,8 +349,8 @@ describe('bodyXMLToTree', () => {
349
349
  "version": 1,
350
350
  }
351
351
  `)
352
- expect(mockLogError).toBeCalled()
353
- expect(mockLogError.mock.lastCall).toMatchInlineSnapshot(`
352
+ expect(mockLogWarn).toBeCalled()
353
+ expect(mockLogWarn.mock.lastCall).toMatchInlineSnapshot(`
354
354
  Array [
355
355
  Object {
356
356
  "error": Object {
@@ -80,7 +80,7 @@ export default function bodyXMLToTree(
80
80
  function logErrors(context?: QueryContext) {
81
81
  if (!context?.aggregatedErrors?.bodyXMLToTree.length) return
82
82
 
83
- context.logger.error({
83
+ context.logger.warn({
84
84
  event: 'RECOVERABLE_ERROR',
85
85
  message: 'Unexpected structure in bodyXMLToTree',
86
86
  error: new OperationalError({
@@ -5,6 +5,7 @@ import {
5
5
  } from '../../../generated'
6
6
  import { QueryContext } from '../../..'
7
7
  import isError from '../../../helpers/isError'
8
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
8
9
 
9
10
  export const Flourish = {
10
11
  async fallbackImage(parent, _args, context) {
@@ -79,9 +80,13 @@ const getImageMetadata = async (
79
80
  }
80
81
  } catch (error) {
81
82
  if (isError(error)) {
82
- context.logger.error({
83
+ context.logger.warn({
83
84
  event: 'RECOVERABLE_ERROR',
84
- error,
85
+ error: new OperationalError({
86
+ code: 'FLOURISH_IMAGE_METADATA_ERROR',
87
+ message: `Error getting image dimensions for Flourish fallback image ${flourishUrl}`,
88
+ cause: error,
89
+ }),
85
90
  })
86
91
  }
87
92
  return {
@@ -4,6 +4,7 @@ import imageServiceUrl from '../../../helpers/imageService'
4
4
  import { RawImage as RawImageNode } from '../Workarounds'
5
5
  import { RawImageResolvers } from '../../../generated'
6
6
  import isError from '../../../helpers/isError'
7
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
7
8
 
8
9
  class RawImageModel implements Image {
9
10
  constructor(private rawImage: RawImageNode, private context: QueryContext) {}
@@ -50,9 +51,13 @@ class RawImageModel implements Image {
50
51
  return imageMetadata
51
52
  } catch (error) {
52
53
  if (isError(error)) {
53
- this.context.logger.error({
54
+ this.context.logger.warn({
54
55
  event: 'RECOVERABLE_ERROR',
55
- error,
56
+ error: new OperationalError({
57
+ code: 'RAW_IMAGE_DIMENSIONS_ERROR',
58
+ message: `Error getting dimensions for raw image ${this.url()}`,
59
+ cause: error,
60
+ }),
56
61
  })
57
62
  }
58
63
  return null
@@ -15,7 +15,7 @@ export const Recommended = {
15
15
  return content
16
16
  } catch (error) {
17
17
  if (error instanceof Error) {
18
- context.logger.error({
18
+ context.logger.warn({
19
19
  event: 'RECOVERABLE_ERROR',
20
20
  error: new OperationalError({
21
21
  code: 'RECOMMENDED_TEASER_ERROR',
@@ -1,3 +1,4 @@
1
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
1
2
  import { TweetResolvers } from '../../../generated'
2
3
  import isError from '../../../helpers/isError'
3
4
 
@@ -10,9 +11,13 @@ export const Tweet = {
10
11
  return tweet.html
11
12
  } catch (error) {
12
13
  if (isError(error)) {
13
- context.logger.error({
14
+ context.logger.warn({
14
15
  event: 'RECOVERABLE_ERROR',
15
- error,
16
+ error: new OperationalError({
17
+ code: 'TWEET_EMBED_ERROR',
18
+ message: `Failed to get HTML embed for tweet ${parent.reference.id}`,
19
+ cause: error,
20
+ }),
16
21
  })
17
22
  }
18
23
  return null
@@ -1,15 +1,29 @@
1
1
  import { uuidFromUrl } from '../../../helpers/metadata'
2
2
  import { VideoReferenceResolvers } from '../../../generated'
3
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
3
4
 
4
5
  export const Video = {
5
6
  // This will override the original id from the parent that is a URL.
6
7
  id: (parent) => uuidFromUrl(parent.reference.id),
7
8
 
8
9
  async title(parent, _args, context) {
9
- const capiResponse = await context.dataSources.capi.getContent(
10
- uuidFromUrl(parent.reference.id)
11
- )
12
- return capiResponse.title()
10
+ try {
11
+ const capiResponse = await context.dataSources.capi.getContent(
12
+ uuidFromUrl(parent.reference.id)
13
+ )
14
+
15
+ return capiResponse.title()
16
+ } catch (error) {
17
+ if (error instanceof Error) {
18
+ throw new OperationalError({
19
+ code: 'VIDEO_CONTENT_FETCH_ERROR',
20
+ message: `Error getting content object for video ${parent.reference.id}`,
21
+ cause: error,
22
+ })
23
+ }
24
+
25
+ throw error
26
+ }
13
27
  },
14
28
 
15
29
  type(parent) {
@@ -2,6 +2,7 @@ import fs from 'fs'
2
2
  import path from 'path'
3
3
  import { QueryResolvers, MutationResolvers } from '../generated'
4
4
  import { CapiResponse } from '../model/CapiResponse'
5
+ import { BaseError, HttpError } from '@dotcom-reliability-kit/errors'
5
6
 
6
7
  const packageJson = JSON.parse(
7
8
  fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8')
@@ -13,7 +14,23 @@ const resolvers = {
13
14
  Query: {
14
15
  version: () => version,
15
16
  async content(_, args, context) {
16
- return context.dataSources.capi.getContent(args.uuid)
17
+ try {
18
+ return await context.dataSources.capi.getContent(args.uuid)
19
+ } catch (error) {
20
+ if (
21
+ error instanceof BaseError &&
22
+ error.data.upstreamStatusCode === 404
23
+ ) {
24
+ throw new HttpError({
25
+ code: 'CONTENT_NOT_FOUND',
26
+ message: `Content ${args.uuid} not found in Content API`,
27
+ statusCode: 404,
28
+ relatesToSystems: ['up-ica'],
29
+ })
30
+ }
31
+
32
+ throw error
33
+ }
17
34
  },
18
35
  contentFromJSON(_, { content }, context) {
19
36
  return CapiResponse.fromJSON(content, context)