Line data Source code
1 : import * as buf from "@bufbuild/protobuf";
2 : import * as wkt from "@bufbuild/protobuf/wkt";
3 : import * as pb from "../../proto.js";
4 :
5 : import { LoggableException } from "../../platform/logger/logger.js";
6 : import type { QueryErrorResponse } from "./hyperdb_http_client.js";
7 :
8 : /// The gRPC "richer error model" trailer that carries the base64-encoded
9 : /// google.rpc.Status. This is the same name the wire uses, mirrored by the
10 : /// native proxy (see proxy_headers.rs HEADER_NAME_GRPC_STATUS_DETAILS).
11 1 : export const HEADER_GRPC_STATUS_DETAILS = "dashql-grpc-status-details-bin";
12 : /// The terse gRPC status message forwarded by the native proxy.
13 1 : export const HEADER_GRPC_MESSAGE = "dashql-grpc-message";
14 : /// The numeric gRPC status code forwarded by the native proxy.
15 1 : export const HEADER_GRPC_STATUS = "dashql-grpc-status";
16 :
17 : /// A text position into the user-provided SQL text.
18 : /// Offsets are measured in unicode code points.
19 : export interface HyperTextPosition {
20 : /// Start offset
21 : beginCharacterOffset: number;
22 : /// End offset
23 : endCharacterOffset: number;
24 : }
25 :
26 : /// The origin of an error, as classified by Hyper.
27 : export type HyperErrorSource = "User" | "System" | "Unknown";
28 :
29 : /// A structured, transport-independent view of Hyper's rich error model.
30 : ///
31 : /// Both the native gRPC path (via the `grpc-status-details-bin` trailer) and
32 : /// the web HTTP v3 path (via the JSON error body) are decoded into this shape
33 : /// so the rest of the app has a single error representation.
34 : export interface HyperErrorInfo {
35 : /// The primary (terse) error message
36 : primaryMessage: string;
37 : /// The SQL state error code (empty means "unknown / internal error")
38 : sqlstate?: string;
39 : /// A suggestion on what to do about the problem
40 : customerHint?: string;
41 : /// Error detail with customer data classification
42 : customerDetail?: string;
43 : /// Error detail with system data classification
44 : systemDetail?: string;
45 : /// Position information pertaining to the error in the SQL text
46 : position?: HyperTextPosition;
47 : /// The cause of the error
48 : errorSource: HyperErrorSource;
49 : /// The numeric gRPC status code, if the error came from a gRPC call
50 : grpcStatusCode?: number;
51 : }
52 :
53 1 : function normalizeErrorSource(raw: string | undefined): HyperErrorSource {
54 1 : switch (raw) {
55 1 : case "User":
56 1 : return "User";
57 0 : case "System":
58 0 : return "System";
59 1 : default:
60 1 : return "Unknown";
61 1 : }
62 1 : }
63 :
64 : /// Convert a decoded protobuf ErrorInfo into our structured HyperErrorInfo.
65 1 : function fromProtoErrorInfo(info: pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfo, grpcStatusCode?: number): HyperErrorInfo {
66 1 : const position = info.position
67 1 : ? {
68 1 : beginCharacterOffset: Number(info.position.errorBeginCharacterOffset),
69 1 : endCharacterOffset: Number(info.position.errorEndCharacterOffset),
70 1 : }
71 1 : : undefined;
72 1 : return {
73 1 : primaryMessage: info.primaryMessage,
74 1 : sqlstate: info.sqlstate || undefined,
75 1 : customerHint: info.customerHint || undefined,
76 1 : customerDetail: info.customerDetail || undefined,
77 1 : systemDetail: info.systemDetail || undefined,
78 1 : position,
79 1 : errorSource: normalizeErrorSource(info.errorSource),
80 1 : grpcStatusCode,
81 1 : };
82 1 : }
83 :
84 : /// Decode a base64-encoded google.rpc.Status (the `grpc-status-details-bin`
85 : /// trailer) and unpack the first packed Hyper ErrorInfo, if any.
86 1 : export function decodeGrpcStatusDetails(base64Details: string, grpcStatusCode?: number): HyperErrorInfo | null {
87 1 : let bytes: Uint8Array;
88 1 : try {
89 1 : const binary = atob(base64Details);
90 1 : bytes = new Uint8Array(binary.length);
91 1 : for (let i = 0; i < binary.length; ++i) {
92 1 : bytes[i] = binary.charCodeAt(i);
93 : }
94 1 : } catch {
95 1 : return null;
96 1 : }
97 :
98 1 : let status: pb.google_rpc.status.Status;
99 1 : try {
100 1 : status = buf.fromBinary(pb.google_rpc.status.StatusSchema, bytes);
101 1 : } catch {
102 0 : return null;
103 0 : }
104 :
105 : // Find the ErrorInfo packed into the repeated Any `details`.
106 1 : for (const detail of status.details) {
107 1 : if (wkt.anyIs(detail, pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfoSchema)) {
108 1 : const info = wkt.anyUnpack(detail, pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfoSchema);
109 1 : if (info) {
110 1 : return fromProtoErrorInfo(info, grpcStatusCode);
111 : }
112 1 : }
113 : }
114 :
115 : // No ErrorInfo packed: fall back to the plain google.rpc.Status message.
116 0 : if (status.message) {
117 0 : return {
118 0 : primaryMessage: status.message,
119 0 : errorSource: "Unknown",
120 0 : grpcStatusCode: grpcStatusCode ?? status.code,
121 0 : };
122 : }
123 0 : return null;
124 1 : }
125 :
126 : /// Extract a HyperErrorInfo from response headers/trailers forwarded by the
127 : /// native gRPC proxy. Returns null if no rich error data is present.
128 1 : export function decodeGrpcErrorHeaders(headers: Headers | null | undefined): HyperErrorInfo | null {
129 1 : if (!headers) {
130 1 : return null;
131 : }
132 1 : const rawStatus = headers.get(HEADER_GRPC_STATUS);
133 1 : const grpcStatusCode = rawStatus != null ? Number.parseInt(rawStatus) : undefined;
134 :
135 1 : const details = headers.get(HEADER_GRPC_STATUS_DETAILS);
136 1 : if (details) {
137 1 : const decoded = decodeGrpcStatusDetails(details, grpcStatusCode);
138 1 : if (decoded) {
139 1 : return decoded;
140 : }
141 1 : }
142 :
143 : // No status-details-bin: fall back to the terse grpc-message.
144 1 : const message = headers.get(HEADER_GRPC_MESSAGE);
145 1 : if (message) {
146 1 : return {
147 1 : primaryMessage: decodeGrpcMessage(message),
148 1 : errorSource: "Unknown",
149 1 : grpcStatusCode,
150 1 : };
151 : }
152 1 : return null;
153 1 : }
154 :
155 : /// The proxy percent-encodes the message like the `grpc-message` trailer, a header value can't hold line breaks
156 1 : function decodeGrpcMessage(message: string): string {
157 1 : try {
158 1 : return decodeURIComponent(message);
159 1 : } catch {
160 1 : return message;
161 1 : }
162 1 : }
163 :
164 : /// Flatten a HyperErrorInfo into a string key/value map for logging and for
165 : /// the generic error-details viewer. Only non-empty fields are included, and
166 : /// `system_detail` is included because these logs stay client-side.
167 1 : export function hyperErrorInfoToKeyValues(info: HyperErrorInfo): Record<string, string> {
168 1 : const kv: Record<string, string> = {};
169 1 : if (info.sqlstate) kv["sqlstate"] = info.sqlstate;
170 1 : if (info.errorSource !== "Unknown") kv["source"] = info.errorSource;
171 1 : if (info.customerHint) kv["hint"] = info.customerHint;
172 1 : if (info.customerDetail) kv["detail"] = info.customerDetail;
173 1 : if (info.systemDetail) kv["systemDetail"] = info.systemDetail;
174 1 : if (info.grpcStatusCode != null) kv["grpcStatus"] = info.grpcStatusCode.toString();
175 1 : if (info.position) {
176 1 : kv["position"] = `${info.position.beginCharacterOffset}..${info.position.endCharacterOffset}`;
177 : }
178 1 : return kv;
179 1 : }
180 :
181 : /// An error raised for a failed Hyper query, carrying the decoded rich error
182 : /// model. Both the native gRPC path and the web HTTP v3 path raise this so the
183 : /// rest of the app has a single, structured error type.
184 1 : export class HyperQueryError extends LoggableException {
185 : /// The decoded rich error info
186 1 : readonly info: HyperErrorInfo;
187 :
188 1 : constructor(info: HyperErrorInfo, target?: string) {
189 1 : super(info.primaryMessage, hyperErrorInfoToKeyValues(info), target);
190 1 : this.info = info;
191 1 : }
192 1 : }
193 :
194 : /// A five-character SQL state, the V3 error field holds one or a word like INTERNAL_ERROR
195 1 : const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/;
196 :
197 : /// Map the web HTTP v3 JSON error body into a HyperErrorInfo.
198 1 : export function fromHttpErrorResponse(response: QueryErrorResponse): HyperErrorInfo {
199 1 : const details = response.details;
200 1 : const position = details?.position
201 1 : ? {
202 1 : beginCharacterOffset: Number.parseInt(details.position.errorBeginCharacterOffset),
203 1 : endCharacterOffset: Number.parseInt(details.position.errorEndCharacterOffset),
204 1 : }
205 1 : : undefined;
206 1 : return {
207 1 : primaryMessage: response.message || response.error,
208 1 : sqlstate: SQLSTATE_PATTERN.test(response.error) ? response.error : undefined,
209 1 : customerHint: details?.customerHint || undefined,
210 1 : customerDetail: details?.customerDetail || undefined,
211 1 : systemDetail: details?.systemDetail || undefined,
212 1 : position,
213 1 : errorSource: normalizeErrorSource(details?.errorSource),
214 1 : };
215 1 : }
|