1import * as AiError from "effect/ai/AiError"
2import * as Duration from "effect/Duration"
3import * as Effect from "effect/Effect"
4import type * as HttpClientError from "effect/http/HttpClientError"
5import type * as HttpClientRequest from "effect/http/HttpClientRequest"
6import type * as HttpClientResponse from "effect/http/HttpClientResponse"
7import * as Num from "effect/Number"
8import * as Option from "effect/Option"
9import * as Redactable from "effect/Redactable"
10import * as Schema from "effect/Schema"
11
12const requestDetails = (request: HttpClientRequest.HttpClientRequest) => ({
13 method: request.method,
14 url: request.url,
15 urlParams: Array.from(request.urlParams),
16 hash: Option.getOrUndefined(request.hash),
17 headers: Redactable.redact(request.headers) as Record<string, string>
18})
19
20const ErrorBody = Schema.Struct({
21 errors: Schema.Array(Schema.Struct({ code: Schema.Number, message: Schema.String }))
22})
23
24const decodeErrorBody = Schema.decodeUnknownOption(ErrorBody)
25
26const retryAfter = (raw: string | undefined): Duration.Duration | undefined => {
27 if (raw === undefined || raw.trim() === "") return undefined
28 const seconds = Option.getOrUndefined(Num.parse(raw))
29 if (seconds !== undefined) return Number.isFinite(seconds) && seconds >= 0 ? Duration.seconds(seconds) : undefined
30 const date = Date.parse(raw)
31 return Number.isNaN(date) ? undefined : Duration.millis(Math.max(0, date - Date.now()))
32}
33
34const fail = (reason: AiError.AiErrorReason) =>
35 Effect.fail(AiError.make({ module: "CloudflareClient", method: "createDecisions", reason }))
36
37export const mapResponseError = Effect.fnUntraced(
38 function*(response: HttpClientResponse.HttpClientResponse): Effect.fn.Return<never, AiError.AiError> {
39 const { request, status } = response
40 const body = yield* response.text.pipe(Effect.orElseSucceed(() => undefined))
41 const parsed = yield* Effect.try(() => JSON.parse(body ?? "")).pipe(Effect.orElseSucceed(() => undefined))
42 const details = Option.getOrUndefined(decodeErrorBody(parsed))
43 const http = {
44 request: requestDetails(request),
45 response: { status, headers: Redactable.redact(response.headers) as Record<string, string> },
46 body
47 }
48 const description = AiError.buildErrorDescription({
49 status,
50 method: request.method,
51 url: request.url,
52 body,
53 message: details?.errors.map((error) => error.message).join("; "),
54 errorCode: details?.errors[0]?.code,
55 requestId: response.headers["cf-ray"]
56 })
57 if (status === 429) {
58 return yield* fail(
59 new AiError.RateLimitError({
60 retryAfter: retryAfter(response.headers["retry-after"]),
61 http,
62 metadata: { cloudflare: { rayId: response.headers["cf-ray"] ?? null, errors: details?.errors ?? [] } }
63 })
64 )
65 }
66 if (status === 404 || status === 422) {
67 return yield* fail(new AiError.InvalidRequestError({ description, http }))
68 }
69 if (status >= 200 && status < 300) {
70 return yield* fail(new AiError.InternalProviderError({ description, http }))
71 }
72 return yield* fail(AiError.reasonFromHttpStatus({ status, description, http }))
73 }
74)
75
76export const mapHttpClientError = Effect.fnUntraced(
77 function*(error: HttpClientError.HttpClientError): Effect.fn.Return<never, AiError.AiError> {
78 const source = error.reason
79 switch (source._tag) {
80 case "TransportError":
81 case "EncodeError":
82 case "InvalidUrlError":
83 return yield* fail(
84 new AiError.NetworkError({
85 reason: source._tag,
86 description: source.description,
87 request: requestDetails(source.request)
88 })
89 )
90 case "DecodeError":
91 case "EmptyBodyError":
92 return yield* fail(
93 new AiError.InvalidOutputError({
94 description: source.description ?? "Failed to decode Cloudflare response"
95 })
96 )
97 case "StatusCodeError":
98 return yield* mapResponseError(source.response)
99 }
100 }
101)