Apollo Server’da Hata Yönetimi ve Özel Hata Sınıfları
GraphQL API geliştirirken en çok göz ardı edilen konulardan biri hata yönetimidir. “Zaten GraphQL hata döndürüyor, ne olacak ki?” diye düşünebilirsiniz, ama production ortamında bir şeyler ters gittiğinde kullanıcıya “Internal server error” yerine anlamlı bir mesaj gösterip gösteremediğiniz, loglarınızda hatayı düzgün izleyip izleyemediğiniz ve frontend ekibinin bu hataları işleyip işleyemediği ciddi fark yaratır. Apollo Server bu konuda oldukça güçlü araçlar sunuyor ve bu yazıda bunları gerçek dünya senaryolarıyla inceleyeceğiz.
GraphQL Hata Yapısı ve Apollo’nun Yaklaşımı
Standart bir GraphQL yanıtında hatalar errors dizisi içinde gelir. Bu dizi, her biri message, locations, path ve extensions alanlarına sahip hata nesnelerinden oluşur. Apollo Server bu yapıyı kullanarak hataları istemciye iletir, ancak varsayılan davranış production için pek de kullanışlı değildir.
# Basit bir GraphQL sorgusu ve hatalı yanıt örneği
curl -X POST http://localhost:4000/graphql
-H "Content-Type: application/json"
-d '{"query": "{ kullanici(id: "999") { ad email } }"}'
# Dönen yanıt:
# {
# "errors": [
# {
# "message": "Cannot read property 'ad' of null",
# "locations": [{"line": 1, "column": 3}],
# "path": ["kullanici"]
# }
# ]
# }
Bu yanıt frontend için pek kullanışlı değil. Kullanıcıya ne göstereceksiniz? “Cannot read property of null” mı? Daha da kötüsü, bu mesaj stack trace içerebilir ve güvenlik açığı oluşturabilir. Apollo Server’ın ApolloError sınıfı ve özel hata sınıfları tam burada devreye giriyor.
ApolloError Temel Sınıfı
Apollo Server, @apollo/server paketinden import edebileceğiniz temel bir GraphQLError sınıfı sağlar. Bu sınıf aracılığıyla hata kodları, HTTP durum kodları ve ek metadata tanımlayabilirsiniz.
# Gerekli paketleri kurun
npm install @apollo/server graphql
npm install --save-dev typescript @types/node
Temel kullanım şu şekildedir:
// src/errors/baseErrors.js
import { GraphQLError } from 'graphql';
// Temel özel hata sınıfı
export class AppError extends GraphQLError {
constructor(message, code, statusCode = 400, additionalInfo = {}) {
super(message, {
extensions: {
code: code,
http: { status: statusCode },
timestamp: new Date().toISOString(),
...additionalInfo
}
});
// Stack trace'i düzgün yakala
Object.defineProperty(this, 'name', { value: 'AppError' });
}
}
// Kullanım örneği
throw new AppError(
'Kullanici bulunamadi',
'USER_NOT_FOUND',
404,
{ userId: '999' }
);
Bu temel yapı üzerine inşa edebileceğimiz özelleştirilmiş hata sınıfları oluşturmak production-ready bir API için şart.
Özel Hata Sınıfları Oluşturmak
Gerçek dünyada karşılaşacağınız hata kategorilerine göre sınıflar tanımlamak, hem kod tekrarını azaltır hem de tutarlı bir hata deneyimi sunar. E-ticaret platformu geliştirdiğinizi düşünelim.
// src/errors/customErrors.js
import { GraphQLError } from 'graphql';
// Kimlik doğrulama hatası
export class AuthenticationError extends GraphQLError {
constructor(message = 'Kimlik doğrulama gerekli') {
super(message, {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 }
}
});
this.name = 'AuthenticationError';
}
}
// Yetkilendirme hatası
export class ForbiddenError extends GraphQLError {
constructor(message = 'Bu işlem için yetkiniz yok', requiredRole = null) {
super(message, {
extensions: {
code: 'FORBIDDEN',
http: { status: 403 },
requiredRole
}
});
this.name = 'ForbiddenError';
}
}
// Kayıt bulunamama hatası
export class NotFoundError extends GraphQLError {
constructor(resource, identifier) {
super(`${resource} bulunamadı: ${identifier}`, {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
resource,
identifier
}
});
this.name = 'NotFoundError';
}
}
// Validasyon hatası
export class ValidationError extends GraphQLError {
constructor(message, field = null, value = null) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
http: { status: 422 },
field,
value
}
});
this.name = 'ValidationError';
}
}
// Rate limit hatası
export class RateLimitError extends GraphQLError {
constructor(retryAfter = 60) {
super('Çok fazla istek gönderdiniz, lütfen bekleyin', {
extensions: {
code: 'RATE_LIMITED',
http: {
status: 429,
headers: {
'Retry-After': String(retryAfter)
}
},
retryAfter
}
});
this.name = 'RateLimitError';
}
}
// İş mantığı hatası
export class BusinessLogicError extends GraphQLError {
constructor(message, code, metadata = {}) {
super(message, {
extensions: {
code: `BUSINESS_${code}`,
http: { status: 400 },
...metadata
}
});
this.name = 'BusinessLogicError';
}
}
Bu sınıfları resolver’larınızda kullanmak oldukça temiz bir kod yazmanızı sağlar.
Resolver’larda Hata Yönetimi
Şimdi bu hata sınıflarını gerçek bir senaryo içinde kullanalım. Bir e-ticaret API’sinde sipariş yönetimi resolver’ları yazıyoruz.
// src/resolvers/siparisResolver.js
import {
AuthenticationError,
ForbiddenError,
NotFoundError,
ValidationError,
BusinessLogicError
} from '../errors/customErrors.js';
import { SiparisService } from '../services/siparisService.js';
import { logger } from '../utils/logger.js';
export const siparisResolvers = {
Query: {
siparis: async (_, { id }, { kullanici }) => {
// Kimlik doğrulama kontrolü
if (!kullanici) {
throw new AuthenticationError();
}
const siparis = await SiparisService.getById(id);
if (!siparis) {
throw new NotFoundError('Sipariş', id);
}
// Yetkilendirme: Sadece kendi siparişini görebilir
if (siparis.kullaniciId !== kullanici.id && kullanici.rol !== 'ADMIN') {
throw new ForbiddenError(
'Bu siparişi görüntüleme yetkiniz yok',
'ADMIN'
);
}
return siparis;
},
siparisListesi: async (_, { sayfa = 1, limit = 10 }, { kullanici }) => {
if (!kullanici) {
throw new AuthenticationError();
}
// Limit validasyonu
if (limit > 100) {
throw new ValidationError(
'Limit değeri 100'den büyük olamaz',
'limit',
limit
);
}
return SiparisService.getList({ kullaniciId: kullanici.id, sayfa, limit });
}
},
Mutation: {
siparisOlustur: async (_, { giris }, { kullanici }) => {
if (!kullanici) {
throw new AuthenticationError();
}
// İş mantığı validasyonu
if (giris.urunler.length === 0) {
throw new ValidationError('Sipariş en az bir ürün içermeli', 'urunler');
}
// Stok kontrolü
for (const urun of giris.urunler) {
const stok = await SiparisService.stokKontrol(urun.urunId, urun.miktar);
if (!stok.yeterli) {
throw new BusinessLogicError(
`${stok.urunAdi} için yeterli stok yok`,
'INSUFFICIENT_STOCK',
{ urunId: urun.urunId, mevcutStok: stok.miktar, istenenMiktar: urun.miktar }
);
}
}
try {
return await SiparisService.create(giris, kullanici.id);
} catch (error) {
// Beklenmeyen hataları logla ama detayları istemciye gönderme
logger.error('Sipariş oluşturma hatası', { error, kullaniciId: kullanici.id });
throw new GraphQLError('Sipariş oluşturulamadı, lütfen tekrar deneyin', {
extensions: { code: 'INTERNAL_ERROR', http: { status: 500 } }
});
}
}
}
};
Apollo Server’da formatError ile Merkezi Hata İşleme
Tüm hataları tek bir noktadan kontrol etmek istiyorsanız Apollo Server’ın formatError seçeneği tam size göre. Bu özellikle production ortamında stack trace’leri gizlemek ve hataları loglamak için kritik.
// src/server.js
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers/index.js';
import { logger } from './utils/logger.js';
const isProd = process.env.NODE_ENV === 'production';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Orijinal hatayı al
const originalError = error instanceof GraphQLError
? error
: new GraphQLError('Bilinmeyen hata');
const errorCode = formattedError.extensions?.code;
const statusCode = formattedError.extensions?.http?.status || 500;
// Production'da dahili hataların detaylarını gizle
if (isProd && statusCode === 500) {
logger.error('Kritik sunucu hatası', {
message: error.message,
stack: error instanceof Error ? error.stack : undefined,
path: formattedError.path,
extensions: formattedError.extensions
});
return {
message: 'Sunucu hatası oluştu',
extensions: {
code: 'INTERNAL_SERVER_ERROR',
http: { status: 500 }
}
};
}
// Development'ta tüm detayları logla
if (!isProd) {
logger.debug('GraphQL hatası', {
message: formattedError.message,
code: errorCode,
path: formattedError.path
});
}
// Özel hata sınıflarını olduğu gibi ilet
return formattedError;
}
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// Context oluşturma
const token = req.headers.authorization?.replace('Bearer ', '');
const kullanici = token ? await dogrulaToken(token) : null;
return { kullanici };
}
});
console.log(`Server hazir: ${url}`);
Hata Kodları ile Frontend Entegrasyonu
Backend’de tutarlı hata kodları tanımlamak, frontend ekibinin hayatını kolaylaştırır. Bir enum veya sabitler dosyası oluşturmak standart bir yaklaşım.
// src/errors/errorCodes.js
export const ERROR_CODES = {
// Kimlik ve yetki hataları
UNAUTHENTICATED: 'UNAUTHENTICATED',
FORBIDDEN: 'FORBIDDEN',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
// Kaynak hataları
NOT_FOUND: 'NOT_FOUND',
ALREADY_EXISTS: 'ALREADY_EXISTS',
CONFLICT: 'CONFLICT',
// Validasyon hataları
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_INPUT: 'INVALID_INPUT',
// İş mantığı hataları
BUSINESS_INSUFFICIENT_STOCK: 'BUSINESS_INSUFFICIENT_STOCK',
BUSINESS_PAYMENT_FAILED: 'BUSINESS_PAYMENT_FAILED',
BUSINESS_ORDER_CANCELLED: 'BUSINESS_ORDER_CANCELLED',
// Sistem hataları
INTERNAL_ERROR: 'INTERNAL_ERROR',
RATE_LIMITED: 'RATE_LIMITED',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE'
};
// Frontend'in kullanabileceği hata mesajları
export const ERROR_MESSAGES = {
[ERROR_CODES.UNAUTHENTICATED]: 'Lütfen giriş yapın',
[ERROR_CODES.FORBIDDEN]: 'Bu işlem için yetkiniz bulunmuyor',
[ERROR_CODES.NOT_FOUND]: 'Aradığınız kaynak bulunamadı',
[ERROR_CODES.RATE_LIMITED]: 'Çok fazla istek gönderildi, lütfen bekleyin',
[ERROR_CODES.INTERNAL_ERROR]: 'Beklenmeyen bir hata oluştu'
};
Frontend Apollo Client tarafında da bu kodları işlemek çok daha temiz bir hata yönetimi sağlar. React uygulamanızda şöyle bir yardımcı fonksiyon kullanabilirsiniz:
// frontend/src/utils/hataIsleyici.js
import { ERROR_CODES, ERROR_MESSAGES } from './errorCodes';
export function graphqlHataIsle(errors) {
if (!errors || errors.length === 0) return null;
const hata = errors[0];
const kod = hata.extensions?.code;
switch (kod) {
case ERROR_CODES.UNAUTHENTICATED:
// Token'ı temizle ve login sayfasına yönlendir
localStorage.removeItem('token');
window.location.href = '/giris';
return null;
case ERROR_CODES.FORBIDDEN:
return {
tip: 'yetki',
mesaj: hata.message || ERROR_MESSAGES[kod],
yonlendir: '/erisim-reddedildi'
};
case ERROR_CODES.VALIDATION_ERROR:
return {
tip: 'validasyon',
mesaj: hata.message,
alan: hata.extensions?.field
};
case ERROR_CODES.RATE_LIMITED:
return {
tip: 'rate_limit',
mesaj: ERROR_MESSAGES[kod],
bekleSaniye: hata.extensions?.retryAfter
};
default:
return {
tip: 'genel',
mesaj: ERROR_MESSAGES[kod] || 'Beklenmeyen bir hata oluştu'
};
}
}
Partial Error Handling: Kısmi Başarı Senaryoları
GraphQL’in güçlü özelliklerinden biri kısmi veri döndürebilmesidir. Bir sorgu birden fazla field içeriyorsa, bir field hata verse bile diğerleri başarıyla döner. Bu senaryoyu doğru yönetmek önemli.
// src/resolvers/kullaniciResolver.js
export const kullaniciResolvers = {
Query: {
kullaniciFull: async (_, { id }, { kullanici }) => {
// Ana kullanıcı verisi
const kullaniciBilgi = await KullaniciService.getById(id);
if (!kullaniciBilgi) {
throw new NotFoundError('Kullanıcı', id);
}
return kullaniciBilgi;
}
},
Kullanici: {
// Bu field hata verse bile diğer field'lar etkilenmez
siparisGecmisi: async (parent) => {
try {
return await SiparisService.getByKullaniciId(parent.id);
} catch (error) {
// null döndürerek partial error oluştur
// GraphQL bu durumda errors dizisine otomatik ekler
logger.warn('Sipariş geçmişi alınamadı', { kullaniciId: parent.id });
return null;
}
},
odemeYontemleri: async (parent, _, { kullanici }) => {
// Yetki kontrolü - sadece kendi profilini görebilir
if (parent.id !== kullanici?.id) {
// null döndür, hata fırlatma - partial error pattern
return null;
}
return OdemeService.getByKullaniciId(parent.id);
}
}
};
Hata İzleme ve Loglama Entegrasyonu
Production ortamında hataları Sentry veya benzeri bir servisle izlemek neredeyse zorunlu. Apollo Server ile bunu entegre etmek için plugin sistemi kullanabilirsiniz.
// src/plugins/errorTrackerPlugin.js
import * as Sentry from '@sentry/node';
export const errorTrackerPlugin = {
async requestDidStart() {
return {
async didEncounterErrors(ctx) {
for (const error of ctx.errors) {
const statusCode = error.extensions?.http?.status || 500;
// 500 seviyesindeki hataları Sentry'e gönder
if (statusCode >= 500) {
Sentry.withScope(scope => {
scope.setTag('graphql_operation', ctx.operation?.operation);
scope.setExtra('query', ctx.request.query);
scope.setExtra('variables', ctx.request.variables);
if (ctx.contextValue?.kullanici) {
scope.setUser({ id: ctx.contextValue.kullanici.id });
}
Sentry.captureException(error);
});
}
// Tüm hataları yapılandırılmış şekilde logla
const logData = {
message: error.message,
code: error.extensions?.code,
status: statusCode,
path: error.path,
operationName: ctx.request.operationName,
kullaniciId: ctx.contextValue?.kullanici?.id
};
if (statusCode >= 500) {
logger.error('GraphQL sunucu hatası', logData);
} else if (statusCode >= 400) {
logger.warn('GraphQL istemci hatası', logData);
}
}
}
};
}
};
// Server'a plugin ekle
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [errorTrackerPlugin],
formatError: (formattedError) => { /* ... */ }
});
Test Yazma: Hata Senaryolarını Test Etmek
Hata yönetiminin doğru çalıştığını test etmek, özellikle güvenlik açısından kritik. Jest ile basit test örnekleri:
// src/__tests__/siparisResolver.test.js
import { ApolloServer } from '@apollo/server';
import { typeDefs } from '../schema.js';
import { resolvers } from '../resolvers/index.js';
describe('Sipariş Resolver Hata Testleri', () => {
let server;
beforeEach(() => {
server = new ApolloServer({ typeDefs, resolvers });
});
test('Kimliksiz kullanıcı UNAUTHENTICATED hatası almalı', async () => {
const response = await server.executeOperation(
{ query: '{ siparis(id: "1") { id } }' },
{ contextValue: { kullanici: null } }
);
expect(response.body.kind).toBe('single');
const errors = response.body.singleResult.errors;
expect(errors).toBeDefined();
expect(errors[0].extensions.code).toBe('UNAUTHENTICATED');
expect(errors[0].extensions.http.status).toBe(401);
});
test('Olmayan sipariş NOT_FOUND hatası döndürmeli', async () => {
const response = await server.executeOperation(
{ query: '{ siparis(id: "999999") { id } }' },
{ contextValue: { kullanici: { id: '1', rol: 'USER' } } }
);
const errors = response.body.singleResult.errors;
expect(errors[0].extensions.code).toBe('NOT_FOUND');
expect(errors[0].extensions.resource).toBe('Sipariş');
});
test('Başka kullanıcının siparişi FORBIDDEN döndürmeli', async () => {
const response = await server.executeOperation(
{ query: '{ siparis(id: "123") { id } }' },
{ contextValue: { kullanici: { id: 'baska_kullanici', rol: 'USER' } } }
);
const errors = response.body.singleResult.errors;
expect(errors[0].extensions.code).toBe('FORBIDDEN');
expect(errors[0].extensions.http.status).toBe(403);
});
});
Sonuç
Apollo Server’da hata yönetimi, düzgün yapıldığında hem geliştirici deneyimini hem de son kullanıcı deneyimini ciddi ölçüde iyileştirir. Özetlemek gerekirse:
- Özel hata sınıfları oluşturun, her kategoriye anlamlı kod ve HTTP durumu atayın
- formatError ile production’da stack trace’leri gizleyin, ancak loglayın
- Tutarlı hata kodları tanımlayın ve frontend ekibiyle belgelendirin
- Plugin sistemi üzerinden merkezi hata izleme entegrasyonu yapın
- Test yazarken yalnızca başarı senaryolarını değil, hata senaryolarını da kapsayın
- Partial error pattern’ini anlayın; bazı durumlarda hata fırlatmak yerine null döndürmek daha doğru olabilir
- Rate limiting ve authentication hatalarını güvenlik perspektifinden ele alın
Bu yaklaşımları benimseyen bir GraphQL API, production’da yaşanan sorunları çok daha hızlı teşhis etmenizi sağlar. Bir sonraki adım olarak hata metriklerini Prometheus ile izleyip Grafana dashboard’u oluşturmayı düşünebilirsiniz; o konu da ayrı bir yazı konusu.
