GraphQL Hata Yönetimi: Özel Error Handling ve Hata Mesajı Standartları
Production ortamında bir GraphQL API çalıştırıyorsunuz ve client tarafından gelen bir hata mesajı şöyle görünüyor: "message": "Internal server error". Bu kadar. Başka hiçbir bilgi yok. Geliştirici ne yapacağını bilemiyor, kullanıcı ne olduğunu anlamıyor, siz de logları karıştırırken saatler harcıyorsunuz. İşte bu yazı tam olarak bu sorunu çözmek için var.
GraphQL’in hata yönetimi, REST API’lere kıyasla hem daha güçlü hem de daha karmaşık bir yapıya sahip. REST’te HTTP status code’ları bir miktar yön gösteriyordu, ama GraphQL’de her şey 200 OK döner ve hataları errors dizisi içinde taşırsınız. Bu yaklaşım esneklik sağlasa da standartlaştırılmamış bir hata yönetimi, zamanla büyük bir kaosa dönüşür.
GraphQL Hata Yapısının Temelleri
GraphQL spec’ine göre bir hata yanıtı şu şekilde görünür:
# Temel GraphQL hata yanıtı örneği
{
"data": null,
"errors": [
{
"message": "Kullanıcı bulunamadı",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "USER_NOT_FOUND",
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
Burada dikkat edilmesi gereken nokta şu: message, locations ve path alanları spec tarafından tanımlanmış, extensions ise sizin özelleştireceğiniz alandır. İşte tüm sihir bu extensions objesinde gerçekleşir.
Ayrıca GraphQL’de kısmi başarı senaryoları da mümkündür. Bir query’nin bir kısmı başarıyla dönerken diğer kısmı hata verebilir:
# Kısmi başarı senaryosu
{
"data": {
"user": {
"id": "123",
"name": "Ahmet Yılmaz",
"orders": null
}
},
"errors": [
{
"message": "Sipariş servisi şu anda erişilemiyor",
"path": ["user", "orders"],
"extensions": {
"code": "SERVICE_UNAVAILABLE",
"service": "order-service"
}
}
]
}
Bu yapıyı anlamak, doğru hata yönetimi stratejisi kurmanın ilk adımıdır.
Özel Hata Sınıfları Oluşturmak
Production ortamında çalışan bir sistemde hataları kategorize etmek şarttır. Bunun için özel hata sınıfları oluşturmalısınız. Node.js üzerinde Apollo Server kullandığınızı varsayalım:
# errors/base.error.js - Temel hata sınıfı
class GraphQLBaseError extends Error {
constructor(message, code, statusCode, additionalInfo = {}) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.statusCode = statusCode;
this.additionalInfo = additionalInfo;
this.timestamp = new Date().toISOString();
}
toGraphQLError() {
return {
message: this.message,
extensions: {
code: this.code,
statusCode: this.statusCode,
timestamp: this.timestamp,
...this.additionalInfo
}
};
}
}
# errors/auth.error.js
class AuthenticationError extends GraphQLBaseError {
constructor(message = "Kimlik doğrulama başarısız") {
super(message, "UNAUTHENTICATED", 401);
}
}
class AuthorizationError extends GraphQLBaseError {
constructor(resource, action) {
super(
`Bu işlem için yetkiniz yok: ${action} -> ${resource}`,
"FORBIDDEN",
403,
{ resource, action }
);
}
}
# errors/validation.error.js
class ValidationError extends GraphQLBaseError {
constructor(fields) {
super("Girilen veriler geçersiz", "VALIDATION_ERROR", 400, { fields });
}
}
# errors/business.error.js
class BusinessLogicError extends GraphQLBaseError {
constructor(message, code, context = {}) {
super(message, code, 422, { context });
}
}
module.exports = {
GraphQLBaseError,
AuthenticationError,
AuthorizationError,
ValidationError,
BusinessLogicError
};
Bu yapı size birçok avantaj sağlar. Hatalarınız tutarlı bir formatta olur, loglama sırasında tip kontrolü yapabilirsiniz ve client tarafı bu hataları güvenle işleyebilir.
Apollo Server’da formatError ile Merkezi Hata Yönetimi
Tüm hataları merkezi bir noktadan geçirmek, hem güvenlik hem de tutarlılık açısından kritiktir. Apollo Server’ın formatError hook’u tam olarak bu iş için tasarlanmıştır:
# server.js - Apollo Server konfigürasyonu
const { ApolloServer } = require('@apollo/server');
const { GraphQLBaseError } = require('./errors');
const logger = require('./logger');
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
# Orijinal hata objesini al
const originalError = error.originalError || error;
# Hassas bilgileri logla ama client'a gönderme
logger.error({
message: originalError.message,
stack: originalError.stack,
code: originalError.code,
path: formattedError.path,
timestamp: new Date().toISOString()
});
# Özel hata sınıflarımızı olduğu gibi döndür
if (originalError instanceof GraphQLBaseError) {
return {
message: originalError.message,
extensions: {
code: originalError.code,
statusCode: originalError.statusCode,
timestamp: originalError.timestamp,
...originalError.additionalInfo
},
path: formattedError.path,
locations: formattedError.locations
};
}
# Production'da bilinmeyen hataları gizle
if (process.env.NODE_ENV === 'production') {
return {
message: "Beklenmeyen bir hata oluştu",
extensions: {
code: "INTERNAL_SERVER_ERROR",
statusCode: 500,
timestamp: new Date().toISOString()
}
};
}
# Development'da tüm detayları göster
return {
...formattedError,
extensions: {
...formattedError.extensions,
stackTrace: originalError.stack
}
};
}
});
Bu yapının en kritik özelliği production/development ayrımıdır. Development ortamında stack trace görmek hayat kurtarırken, production’da bu bilgileri dışarıya sızdırmak güvenlik açığı oluşturur.
Resolver’larda Hata Yönetimi Pratikleri
Resolver seviyesinde hata yönetimi, çoğu zaman ihmal edilen ama son derece önemli bir konudur. Her resolver’da try-catch yazmak hem tekrar eden kod üretir hem de bakımı zorlaştırır. Bunun yerine bir wrapper fonksiyonu kullanabilirsiniz:
# utils/resolver-wrapper.js
const { AuthenticationError, BusinessLogicError } = require('../errors');
const withErrorHandling = (resolverFn) => {
return async (parent, args, context, info) => {
try {
# Auth kontrolü
if (!context.user) {
throw new AuthenticationError();
}
return await resolverFn(parent, args, context, info);
} catch (error) {
# Zaten bizim hata tipimizse direkt fırlat
if (error instanceof GraphQLBaseError) {
throw error;
}
# Database connection hatası
if (error.code === 'ECONNREFUSED') {
throw new BusinessLogicError(
"Veritabanı bağlantısı kurulamadı",
"DATABASE_CONNECTION_ERROR",
{ originalCode: error.code }
);
}
# Timeout hatası
if (error.message.includes('timeout')) {
throw new BusinessLogicError(
"İşlem zaman aşımına uğradı",
"TIMEOUT_ERROR",
{ timeout: context.requestTimeout }
);
}
# Bilinmeyen hataları logla ve genel hata fırlat
console.error("Bilinmeyen resolver hatası:", error);
throw new Error("İşlem gerçekleştirilemedi");
}
};
};
# resolvers/user.resolver.js
const { ValidationError, AuthorizationError } = require('../errors');
const { withErrorHandling } = require('../utils/resolver-wrapper');
const userResolvers = {
Query: {
getUser: withErrorHandling(async (parent, { id }, context) => {
if (!id || id.length < 5) {
throw new ValidationError([
{ field: "id", message: "Geçersiz kullanıcı ID formatı" }
]);
}
const user = await UserService.findById(id);
if (!user) {
throw new BusinessLogicError(
`ID'si ${id} olan kullanıcı bulunamadı`,
"USER_NOT_FOUND",
{ userId: id }
);
}
if (user.tenantId !== context.user.tenantId) {
throw new AuthorizationError("user", "read");
}
return user;
})
}
};
Validation Hatalarını Standartlaştırmak
Form doğrulama hataları, API’lerin en sık karşılaştığı hata türüdür. Bunları standart bir formatta sunmak, frontend geliştiricilerin işini çok kolaylaştırır:
# validation/schema.validator.js
const Joi = require('joi');
const { ValidationError } = require('../errors');
const createUserSchema = Joi.object({
email: Joi.string().email().required().messages({
'string.email': 'Geçerli bir e-posta adresi giriniz',
'any.required': 'E-posta adresi zorunludur'
}),
password: Joi.string().min(8).required().messages({
'string.min': 'Şifre en az 8 karakter olmalıdır',
'any.required': 'Şifre zorunludur'
}),
age: Joi.number().min(18).max(120).messages({
'number.min': 'Yaş 18 veya üzeri olmalıdır'
})
});
const validateCreateUser = (input) => {
const { error } = createUserSchema.validate(input, { abortEarly: false });
if (error) {
const fields = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message,
type: detail.type
}));
throw new ValidationError(fields);
}
};
# Bu durumda client şu yanıtı alır:
# {
# "errors": [{
# "message": "Girilen veriler geçersiz",
# "extensions": {
# "code": "VALIDATION_ERROR",
# "statusCode": 400,
# "fields": [
# { "field": "email", "message": "Geçerli bir e-posta adresi giriniz", "type": "string.email" },
# { "field": "password", "message": "Şifre en az 8 karakter olmalıdır", "type": "string.min" }
# ]
# }
# }]
# }
Bu formatta validation hatalarını alan bir frontend geliştirici, hangi alana hangi mesajı göstereceğini tam olarak bilir. Bu durum gereksiz iletişim yükünü ortadan kaldırır.
Hata Kodları Standartları
Tutarlı hata kodları oluşturmak, büyük ekiplerde özellikle kritik öneme sahiptir. Bunun için bir constants dosyası oluşturmanızı tavsiye ederim:
# constants/error-codes.js
const ERROR_CODES = {
# Authentication & Authorization
UNAUTHENTICATED: {
code: "UNAUTHENTICATED",
statusCode: 401,
message: "Bu işlem için giriş yapmanız gerekiyor"
},
FORBIDDEN: {
code: "FORBIDDEN",
statusCode: 403,
message: "Bu işlem için yetkiniz bulunmuyor"
},
TOKEN_EXPIRED: {
code: "TOKEN_EXPIRED",
statusCode: 401,
message: "Oturumunuzun süresi doldu, lütfen tekrar giriş yapın"
},
# Resource Errors
NOT_FOUND: {
code: "NOT_FOUND",
statusCode: 404,
message: "İstenen kaynak bulunamadı"
},
ALREADY_EXISTS: {
code: "ALREADY_EXISTS",
statusCode: 409,
message: "Bu kaynak zaten mevcut"
},
# Validation
VALIDATION_ERROR: {
code: "VALIDATION_ERROR",
statusCode: 400,
message: "Girilen veriler geçersiz"
},
INVALID_INPUT: {
code: "INVALID_INPUT",
statusCode: 400,
message: "Geçersiz giriş formatı"
},
# Business Logic
INSUFFICIENT_BALANCE: {
code: "INSUFFICIENT_BALANCE",
statusCode: 422,
message: "Yetersiz bakiye"
},
QUOTA_EXCEEDED: {
code: "QUOTA_EXCEEDED",
statusCode: 429,
message: "Kullanım kotanız doldu"
},
# System Errors
INTERNAL_SERVER_ERROR: {
code: "INTERNAL_SERVER_ERROR",
statusCode: 500,
message: "Beklenmeyen bir hata oluştu"
},
SERVICE_UNAVAILABLE: {
code: "SERVICE_UNAVAILABLE",
statusCode: 503,
message: "Servis şu anda kullanılamıyor"
},
DATABASE_ERROR: {
code: "DATABASE_ERROR",
statusCode: 500,
message: "Veritabanı işlemi başarısız oldu"
}
};
module.exports = ERROR_CODES;
Bu standartları belgeleyip ekiple paylaştığınızda, herkes aynı dili konuşur. Yeni bir geliştirici projeye dahil olduğunda hangi hata kodunun ne anlama geldiğini kolayca anlayabilir.
Client Tarafında Hata Yönetimi
Sunucu tarafında ne kadar güzel hata yapıları oluşturursanız oluşturun, client tarafında bunları doğru işlemezseniz işe yaramaz. React ve Apollo Client kullanan bir senaryoda:
# hooks/useGraphQLError.js
const useGraphQLError = () => {
const handleError = (error) => {
if (!error.graphQLErrors?.length) {
# Network hatası
if (error.networkError) {
return {
type: "NETWORK",
message: "İnternet bağlantınızı kontrol edin"
};
}
return { type: "UNKNOWN", message: "Bilinmeyen hata" };
}
const gqlError = error.graphQLErrors[0];
const code = gqlError.extensions?.code;
const fields = gqlError.extensions?.fields;
switch (code) {
case "UNAUTHENTICATED":
case "TOKEN_EXPIRED":
# Kullanıcıyı login sayfasına yönlendir
localStorage.removeItem('token');
window.location.href = '/login';
return null;
case "FORBIDDEN":
return {
type: "AUTH",
message: "Bu işlem için yetkiniz bulunmuyor"
};
case "VALIDATION_ERROR":
# Field-level hataları döndür
return {
type: "VALIDATION",
fields: fields?.reduce((acc, curr) => {
acc[curr.field] = curr.message;
return acc;
}, {})
};
case "NOT_FOUND":
return {
type: "NOT_FOUND",
message: gqlError.message
};
default:
return {
type: "GENERAL",
message: gqlError.message || "İşlem gerçekleştirilemedi"
};
}
};
return { handleError };
};
# Bileşende kullanım
const CreateUserForm = () => {
const { handleError } = useGraphQLError();
const [fieldErrors, setFieldErrors] = useState({});
const [createUser] = useMutation(CREATE_USER_MUTATION, {
onError: (error) => {
const processed = handleError(error);
if (processed?.type === "VALIDATION") {
setFieldErrors(processed.fields);
} else if (processed?.type === "GENERAL") {
toast.error(processed.message);
}
}
});
};
Hata İzleme ve Observability
Hata yönetiminin son halkası, hataları izlemek ve analiz etmektir. Sentry entegrasyonu ile bunu nasıl yapabileceğinize bakalım:
# middleware/error-tracking.js
const Sentry = require('@sentry/node');
const setupErrorTracking = (server) => {
server.addPlugin({
requestDidStart() {
return {
didEncounterErrors(ctx) {
ctx.errors.forEach(error => {
const originalError = error.originalError;
# Validation ve auth hatalarını Sentry'ye gönderme
# Bunlar beklenen hatalar
if (
originalError?.code === 'VALIDATION_ERROR' ||
originalError?.code === 'UNAUTHENTICATED' ||
originalError?.code === 'FORBIDDEN' ||
originalError?.code === 'NOT_FOUND'
) {
return;
}
# Beklenmeyen hataları Sentry'ye gönder
Sentry.withScope(scope => {
scope.setTag("graphql.operation", ctx.operation?.name?.value);
scope.setTag("graphql.type", ctx.operation?.operation);
scope.setContext("graphql", {
query: ctx.request.query,
variables: ctx.request.variables,
path: error.path
});
if (ctx.context.user) {
scope.setUser({
id: ctx.context.user.id,
email: ctx.context.user.email
});
}
Sentry.captureException(originalError || error);
});
});
}
};
}
});
};
Bu yapı sayesinde gerçekten önemli olan hataları Sentry’ye gönderirken beklenen iş mantığı hatalarını filtrelemiş olursunuz. Alert yorgunluğunu önlemek için bu ayrımı yapmak son derece önemlidir.
Gerçek Dünya Senaryosu: E-ticaret API’si
Bir e-ticaret sisteminde sipariş oluşturma akışını düşünelim. Bu tek bir işlemde birden fazla hata senaryosu barındırır:
# resolvers/order.resolver.js
const createOrderResolver = async (parent, { input }, context) => {
const { userId, items, paymentMethod } = input;
# 1. Stok kontrolü
for (const item of items) {
const product = await ProductService.findById(item.productId);
if (!product) {
throw new BusinessLogicError(
`Ürün bulunamadı: ${item.productId}`,
"PRODUCT_NOT_FOUND",
{ productId: item.productId }
);
}
if (product.stock < item.quantity) {
throw new BusinessLogicError(
`Yetersiz stok: ${product.name}`,
"INSUFFICIENT_STOCK",
{
productId: item.productId,
productName: product.name,
requestedQuantity: item.quantity,
availableStock: product.stock
}
);
}
}
# 2. Bakiye kontrolü
const totalAmount = await calculateTotal(items);
const userBalance = await PaymentService.getBalance(userId);
if (userBalance < totalAmount) {
throw new BusinessLogicError(
"Yetersiz bakiye",
"INSUFFICIENT_BALANCE",
{
requiredAmount: totalAmount,
currentBalance: userBalance,
difference: totalAmount - userBalance
}
);
}
# 3. Sipariş oluştur
const order = await OrderService.create({ userId, items, paymentMethod });
return order;
};
Bu resolver’da her hata durumu için hem kullanıcıya anlamlı mesaj hem de frontend’in işleyebileceği context bilgisi sağlanıyor. Yetersiz stok hatası geldiğinde frontend availableStock bilgisini göstererek kullanıcıya “X adet yerine Y adet ekleyebilirsiniz” mesajı verebilir.
Sonuç
GraphQL hata yönetimi, bir API’nin olgunluğunu ve profesyonelliğini gösteren en önemli göstergelerden biridir. Özetlemek gerekirse:
- Özel hata sınıfları oluşturarak tip güvenliği ve tutarlılık sağlayın
formatErrorhook’u ile merkezi hata işleme yapın ve production’da hassas bilgileri gizleyin- Standart hata kodları tanımlayarak ekip içinde ortak dil oluşturun
- Validation hatalarını field-level detaylarla döndürerek frontend’in işini kolaylaştırın
- Client tarafında hata kodlarına göre akıllıca işleme mantığı kurun
- Observability için beklenen hataları filtreyip gerçek sorunları izleyin
En başta bahsettiğimiz "Internal server error" senaryosuna dönersek: Bu yazıdaki yaklaşımları uyguladığınızda, aynı hata artık şöyle görünecek: "code": "PRODUCT_NOT_FOUND", "productId": "abc-123", "timestamp": "2024-01-15T10:30:00Z". Geliştirici hemen ne arayacağını bilir, kullanıcıya doğru mesaj gösterilir, siz de gece yarısı log karıştırmak yerine uyursunuz.
