ข้ามไปยังเนื้อหา

Error Extensions

message มีไว้ให้มนุษย์อ่าน log แต่ client ไม่สามารถแตกกิ่งโปรแกรมบนประโยคได้อย่างน่าเชื่อถือ เพราะสตริงถูกเรียบเรียงใหม่ ถูกแปล และถูกจัดรูปแบบใหม่ได้ เพื่อให้ client ตอบสนองต่อความล้มเหลวในเชิงโปรแกรมได้ GraphQL จึงให้ทุก error มี extensions map ที่เป็น object อิสระที่ server กำหนดเอง ห้อยอยู่กับ error entry นี่คือที่ที่ typed errors อาศัยอยู่

extensions คือ key เดียวของ error ที่ specification เปิดทิ้งไว้ให้คุณเติมเอง ธรรมเนียมที่แทบจะเป็นสากลคือใส่สตริง code ที่คงที่ลงไปในนั้น

{
"errors": [
{
"message": "No track exists with that id.",
"path": ["track"],
"extensions": { "code": "NOT_FOUND" }
}
]
}

message อาจเปลี่ยนไปตามกาลเวลา แต่ extensions.code คือ contract เช่น "NOT_FOUND", "UNAUTHENTICATED", "FORBIDDEN", "BAD_USER_INPUT" client switch บน code ไม่ใช่บนตัวประโยค คุณยังแนบอะไรก็ตามที่คุณเห็นว่ามีประโยชน์ได้ด้วย ไม่ว่าจะเป็นคำใบ้ให้ลองใหม่ ชื่อ field หรือ correlation id ตราบใดที่ปลอดภัยพอจะเปิดเผยออกไป

reference implementation export class GraphQLError มาให้ throw instance ของ class นี้จาก resolver แล้วส่ง extensions เข้าไปใน options จากนั้น engine จะร้อยเข้า response ให้เอง

import { GraphQLError } from 'graphql';
throw new GraphQLError('No track exists with that id.', {
extensions: { code: 'NOT_FOUND' },
});

ลองเทียบกับการ throw Error ธรรมดาดู Error ธรรมดาก็ให้ error entry ที่ถูกต้องได้เหมือนกัน อย่างที่เห็นในบทก่อน ๆ แต่ไม่มี code ติดมา client จึงต้องไปแกะ message เอาเอง การเลือกใช้ GraphQLError คือวิธีทำให้ความล้มเหลวเป็นส่วนหนึ่งของ typed contract แทนที่จะขึ้นอยู่กับถ้อยคำโดยบังเอิญ

มีแง่มุมด้านความปลอดภัยด้วย exception ที่ไม่คาดคิด เช่น การเรียก database ที่ล้มเหลว หรือการ dereference null ลึก ๆ ใน code ของคุณ มักพก stack trace หรือข้อความภายในที่ห้ามส่งไปให้ client เด็ดขาด รูปแบบเชิงป้องกันคือ ปล่อยให้ความล้มเหลวที่ คาดไว้ throw GraphQLError พร้อม code ที่ตั้งใจ แล้ว ดักทุกอย่างที่เหลือ เปลี่ยนเป็น masked error กลาง ๆ ก่อนหลุดออกจาก server

flowchart TD
  Throw["Resolver throws"]
  Check{"Is it a deliberate GraphQLError?"}
  Keep["Expected: keep message + code (NOT_FOUND, FORBIDDEN, ...)"]
  Mask["Unexpected: log server-side, return generic INTERNAL_SERVER_ERROR"]
  Client["Client reads extensions.code"]
  Throw --> Check
  Check -->|"yes"| Keep
  Check -->|"no"| Mask
  Keep --> Client
  Mask --> Client
ความล้มเหลวที่คาดไว้คง code เดิมไว้ ส่วนที่ไม่คาดคิดถูกปกปิด

ใน production โดยทั่วไปคุณจะ log exception จริงไว้ฝั่ง server (พร้อม correlation id) และ return ให้ client เพียง { message: "Internal server error", extensions: { code: "INTERNAL_SERVER_ERROR" } } ที่จืดชืด ผู้ใช้ได้ข้อมูลพอที่จะลองใหม่หรือรายงาน ส่วนผู้โจมตีไม่ได้เรียนรู้อะไรเกี่ยวกับภายในของคุณเลย GraphQL server หลายตัวปกปิดให้เป็นค่าเริ่มต้นอยู่แล้ว หลักการเหมือนกันไม่ว่าจะเป็น framework หรือ catch block ของคุณเองที่ทำ

runner ด้านล่าง throw GraphQLError พร้อม extensions: { code: 'NOT_FOUND' } เมื่อหา track ไม่เจอ กด Run แล้วดู error entry จะเห็น code ของคุณวางอยู่ข้าง ๆ message และ path เลย

JavaScript

ใน output นั้น data.track เป็น null เพราะ field เป็น nullable และ error entry เพียงตัวเดียวตอนนี้พก extensions.code === "NOT_FOUND" พร้อมคำใบ้ argument ที่เราแนบไว้ client สามารถอ่าน code นั้นและแสดงผลว่า “track not found” ได้โดยไม่ต้องไปแกะข้อความภาษาอังกฤษเลย ที่เป็น contract เชิงโปรแกรมที่ Error ธรรมดาให้เราไม่ได้

ข้อดีข้อแลกเปลี่ยน
error code ทำให้ client จัดการ error programmaticallyextensions ที่มากเกินทำให้ error response ใหญ่
timestamp, requestId ใน extensions ช่วย debugsensitive information ใน extensions อาจ leak ได้
custom extension ให้ข้อมูล domain-specific errorformat extensions ไม่มีมาตรฐาน — ต้องตกลงกันเอง
stack trace ใน extensions ช่วย developmentstack trace ใน production เป็น security risk

Stack Trace ใน Production Response

อาการ:

  • extensions มี stacktrace array แม้ใน production
  • expose internal code path และ file structure ให้ผู้โจมตี
  • ปิด stack trace ใน production: formatError ที่ filter out stacktrace เมื่อ NODE_ENV === 'production'

Error Code ที่ไม่ Documented

อาการ:

  • client ได้ "code": "ERR_42" แต่ไม่รู้ว่าหมายถึงอะไร
  • developer ต้องถามหรือดู source code เพื่อรู้ error code
  • document error code ทั้งหมดใน schema description หรือ API doc

💡 ตัวอย่างจากของจริง

Apollo Server:

  • error extensions มี code (enum) และ http.status สำหรับ custom HTTP status
  • production build ซ่อน stacktrace อัตโนมัติ

Stripe:

  • error code เป็น machine-readable string: card_declined, insufficient_funds
  • client จัดการแต่ละ error code ต่างกันโดยไม่ต้อง parse error message
extensions map บน error entry ใช้ทำอะไร?
ทำไม client ควรแตกกิ่งบน extensions.code แทนที่จะบน message?
คุณแนบ typed code เข้ากับ error จาก resolver ได้อย่างไร?
การจัดการ exception ภายในที่ไม่คาดคิดใน production ที่แนะนำคืออะไร?