| 1 | import * as AiError from "effect/ai/AiError" |
| 2 | import * as Duration from "effect/Duration" |
| 3 | import * as Effect from "effect/Effect" |
| 4 | import type * as HttpClientError from "effect/http/HttpClientError" |
| 5 | import type * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 6 | import type * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 7 | import * as Num from "effect/Number" |
| 8 | import * as Option from "effect/Option" |
| 9 | import * as Redactable from "effect/Redactable" |
| 10 | import * as Schema from "effect/Schema" |
| 11 | |
| 12 | const 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 | |
| 20 | const ErrorBody = Schema.Struct({ |
| 21 | errors: Schema.Array(Schema.Struct({ code: Schema.Number, message: Schema.String })) |
| 22 | }) |
| 23 | |
| 24 | const decodeErrorBody = Schema.decodeUnknownOption(ErrorBody) |
| 25 | |
| 26 | const 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 | |
| 34 | const fail = (reason: AiError.AiErrorReason) => |
| 35 | Effect.fail(AiError.make({ module: "CloudflareClient", method: "createDecisions", reason })) |
| 36 | |
| 37 | export 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 | |
| 76 | export 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 | ) |