Menjadi Developer Web dengan Python dan Flask Bagian VII: Error Handling

Bagus Aji Santoso 1 Maret 2018

Menjadi Developer Web dengan Python dan Flask Bagian VII: Error Handling

Artikel ini merupakan terjemahan The Flask Mega-Tutorial Part VII: Error Handling dari seri The Flask Mega-Tutorial karya Miguel Grinberg yang akan dirilis secara gratis bab per bab sampai bulan Mei 2018. Dukung penulis aslinya dengan membeli buku/video tutorial lengkapnya di sini.

Di bab ini, kita akan rehat sejenak dari ngoding untuk menambah fitur baru di aplikasi microblog. Kita kan mendikusikan sebentar beberapa strategi untuk menyelesaikan bug yang akan sering muncul di project aplikasi apa pun. Untuk membantu mengilustrasikan topik ini, penulis sengaja membiarkan satu bug muncul dari bab sebelumnya. Sebelum melanjutkan membaca tutorial ini, coba apakah pembaca bisa menemukannya?

*Tautan GitHub untuk bab ini: Browse, Zip, Diff. *

Error Handling di Flask

Kira-kira apa yang akan terjadi saat error muncul di aplikasi Flask? Cara terbaik untuk mengetahuinya ialah dengan mengalaminya secara langsung. Sekarang jalankan aplikasinya, lalu pastiakn sudah memiliki dua user terdaftar. Masuk sebagai salah satu user, buka halaman profil dan klik tautan "Edit". Didalam editor untuk profil, coba ubah username dengan username yang sudah dimiliki user lain, dan boom! Akan muncul halaman "Internal Server Error" yang agak seram.

Internal Server Error

Jika pembaca melihat pesan yang muncul di sesi terminal saat aplikasi dijalankan, pembaca akan melihat stack trace dari kesalahan yang terjadi. Stack trace sangat berguna untuk mencari error karena mereka memberikan urutan pemanggilan mana yang menyebabkan sebuah error terjadi:

(venv) $ flask run
 * Serving Flask app "microblog"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
[2017-09-14 22:40:02,027] ERROR in app: Exception on /edit_profile [POST]
Traceback (most recent call last):
  File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1182, in _execute_context
    context)
  File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 470, in do_execute
    cursor.execute(statement, parameters)
sqlite3.IntegrityError: UNIQUE constraint failed: user.username

Stack trace akan memperlihatkan bagian mana yang menjadi penyebabnya. Aplikasi kita memperbolehkan seorang user mengganti username, tapi tidak memvalidasi apakah username baru yang dipilih bentrok dengan user lain di sistem. Error ini muncul dari SQLAlchemy yang mencoba menulis username baru ke database, tapi database menolak karena kolom username sebelumnya sudah diatur dengan unique=True.

Penting untuk diingat bahwa halaman error yang ditampilkan ke user tidak memberikan informasi lengkap, dan itu adalah hal yang bagus. Kita tentu tidak ingin user mengetahui secara pasti bahwa kesalahan ini disebabkan oleh database error, database apa yang kita gunakan, atau tabel dan nama kolom apa saja yang ada di database. Informasi-informasi yang lebih detail itu harus dirahasiakan dari user.

Ada satu hal yang juga belum cukup ideal. Halaman error kita masih sangat jelek dan tidak memiliki layout aplikasi yang sudah dibangun. Kita juga memiliki stack trace aplikasi diterminal yang harus selalu diperhatikan agar tidak kecolongan error yang lain. Sebelum memperbaiki bug yang ada, mari kita bahas tentang debug mode di Flask.

Debug Mode

Halaman error yang muncul sebelumnya cocok dipakai oleh aplikasi yang sudah diunggah ke sebuah production server. Jika ada error, user akan diberitahu dengan sebuah halaman khusus (yang nanti akan kita perbagus), dengan pesan error yang lebih detail disimpan di file log server.

Tapi saat aplikasinya sedang dibuat, kita tentu menginginkan debug mode untuk diaktifkan. Jika Flask aktif dalam mode ini, kita akan mendapatkan pesan error yang sangat membantu yang akan ditampilkan di browser. Untuk mengaktifkan debug mode, stop dulu aplikasi, lalu atur environment variable berikut:

(venv) $ export FLASK_DEBUG=1

Jika menggunakan Microsoft Windows, ingat untuk menggunakan set bukan export.

Setelah mengatur FLASK_DEBUG, restart ulang server. Pesan yang ditampilkan saat memulai server menjadi agak berbeda dibanding sebelumnya:

(venv) microblog2 $ flask run
 * Serving Flask app "microblog"
 * Forcing debug mode on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 177-562-960

Sekarang buat aplikasi crash sekali lagi seperti sebelumnya untuk melihat pesan interactive debugger di browser:

Flask Debugger

Debugger akan memungkinkan kita meng-expand setiap stack frame dan melihat source code yang terkait. Pembaca juga bisa membuka prompt Python di frame manapun dan mengeksekusi perintah Python yang valid, misalnya untuk memeriksa isi dari suatu variabel.

Sangat penting untuk tidak mengaktifkan debug mode di production server. Debugger memungkinkan user untuk mengeksekusi kode di server dari jauh sehingga bisa membuat mereka masuk ke dalam sistem kita. Sebagai keamanan tambahan, debugger yang berjalan di browser akan dikunci terlebih dahulu dan meminta nomor PIN yang bisa dilihat saat menjalankan perintah flask run.

Karena masih membahas tentang debug mode, ada satu fitur penting lain yang akan aktif di debug mode yaitu reloader. Fitur ini sangat berguna saat development karena secara otomatis akan me-restart aplikasi jika ada file yang kode-nya dimodifikasi.

Custom Error Pages

Flask memberikan sebuah mekanisme bagi aplikasi untuk memasang halaman error khusus sehingga user tidak perlu melihat halaman awal yang biasa-biasa saja. Untuk contoh, mari kita buat halaman error untuk kode HTTP 404 dan 500, dua kesalahan yang paling sering terjadi. Membuat halaman untuk halaman error lain tidak berbeda.

Untuk membuat custom error handler, dekorator @errorhandler akan dipakai. Disini kita akan menulis error handler di file app/errors.py.

app/errors.py: Custom error handlers

from flask import render_template
from app import app, db

@app.errorhandler(404)
def not_found_error(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return render_template('500.html'), 500

Fungsi untuk error handling sangat mirip dengan fungsi view. Untuk kedua jenis error tadi, kita akan menampilkan template khusus. Perhatikan bahwa kedua fungsi tersebut mengirimkan nilai kedua selain template yaitu kode nomor error-nya. Untuk semua fungsi view yang sudah kita buat, kita tidak perlu mengirimkan kode nomor 200 (untuk menandakan successful response) karena sudah diberikan secara otomatis. Karena kedua fungsi di atas merupakan fungsi untuk menangani halaman error khusus, maka kita perlu memberikan kode status untuk merefleksikan jenis error apa yang akan mereka tangani.

Error handler untuk kode 500 dapat dipanggil setelah sebuah database error, yang salah satu kasusnya terjdi bila ada username yang sama. Untuk memastikan semua percobaan database yang gagal tidak mempengaruhi database yang sudah ada, kita memanggil sesi rollback. Sesi ini akan membersihkan database dari percobaan mengisi data yang sebelumnya gagal (sehingga data yang terubah tidak setengah-setengah).

Berikut ini template untuk halaman error 404:

app/templates/404.html: Not found error template

{% extends "base.html" %}

{% block content %}
    <h1>File Not Found</h1>
    <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

Dan berikut ini adalah halaman error 500:

app/templates/500.html: Internal server error template

{% extends "base.html" %}

{% block content %}
    <h1>An unexpected error has occurred</h1>
    <p>The administrator has been notified. Sorry for the inconvenience!</p>
    <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

Kedua template meng-extends base.html, sehingga mereka akan memiliki tampilan seperti halaman aplikasi yang normal.

Agar error handler yang sudah kita tulis terdaftar di Flask, kita perlu mengimpor file app/errors.py sesudah menginisiasi aplikasi:

app/init.py: Import error handlers

# ...

from app import routes, models, errors

Jika sudah mematikan debug mode dengan FLASK_DEBUG=0 di sesi terminal lalu mencoba menganti username sekali lagi, maka kita akan mendapatkan halaman error yang sedikit lebih bersahabat.

Custom 500 Error Page

Mengirim Error Melalui Email

Masalah lain dengan error handler bawaan Flask adalah tidak ada notifikasi, stack trace untuk setiap error dicetak di terminal, yang artinya output dari proses server harus dimonitor untuk melihat jika terjadi error. Saat aplikasi dijalankan saat melakukan pengembangan hal ini bisa dimaklumi, tapi jika aplikasi sudah di kirim ke server, siapa yang akan memeriksa output yang dikeluarkan? Jadi solusi yang lebih baik diperlukan disini.

Kita ingin saat error terjadi di versi production, kita langsung diberi tahu. Jadi, solusi pertama yang akan kita lakukan akan mengatur Flask agar mengirim email setiap terjadi error. Email ini akan berisi stack trace error yang terjadi.

Langkah pertama yang mesti dilakukan adalah memberikan detail server email ke file configuration:

config.py: Email configuration

class Config(object):
    # ...
    MAIL_SERVER = os.environ.get('MAIL_SERVER')
    MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    ADMINS = ['your-email@example.com']

Variabel configuration untuk email diantaranya adalah server, port, penanda untuk mengaktifkan koneksi terenkripsi atau tidak, disertai dengan username dan password. Kelima variabel ini diambil dari environment variable. Jika server email tidak diatur di environment variable, maka itu akan menjadi pertanda bahwa pengiriman error email perlu dimatikan. Port server email juga perlu dimasukkan di environment variable, tapi jika tidak diatur, port standar nomor 25 akan dipakai. Data username dan password tidak wajib diberikan. Variabel ADMIN adalah daftar email yang akan menerima email error, jadi pastikan email pembaca tertulis di sana.

Flask menggunakan paket logging dari Python untuk menulis log, dan paket ini sudah memiliki kemampuan untuk mengirim log via email. Yang perlu dilakukan untuk mengirimkan pesan log tersebut ke email ialah menambahkan sebuah instance SMTPHandler ke objek Flask logger, yang bernama app.logger:

app/init.py: Log errors via email

import logging
from logging.handlers import SMTPHandler

# ...

if not app.debug:
    if app.config['MAIL_SERVER']:
        auth = None
        if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
            auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD'])
        secure = None
        if app.config['MAIL_USE_TLS']:
            secure = ()
        mail_handler = SMTPHandler(
            mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
            fromaddr='no-reply@' + app.config['MAIL_SERVER'],
            toaddrs=app.config['ADMINS'], subject='Microblog Failure',
            credentials=auth, secure=secure)
        mail_handler.setLevel(logging.ERROR)
        app.logger.addHandler(mail_handler)

Seperti yang bisa pembaca lihat, kita hanya akan mengaktifkan email logger jika aplikasi dijalankan tanpa debug mode saat nilai app.debug berisi True juga saat server email ada di file configuration.

Kode-kode di atas akan membuat sebuah instance dari SMTPHandler, mengatur level-nya sehingga hanya membuat laporan error bukan warning, informational atau debugging message, lalu mengirim laporan error tersebut ke objek app.logger dari Flask.

Ada dua cara untuk menguji fitur ini. Cara pailng mudah ialah dengan menggunakan server debugging SMTP dari Python. Server ini adalah server email fake , bukannya mengirim, ia akan mencetak email ke console (terminal). Untuk menjalankan server ini, buka tab baru atau jendela terminal baru lalu jalankan perintah berikut:

(venv) $ python -m smtpd -n -c DebuggingServer localhost:8025

Untuk menguji kode yang kita buat dengan server ini, atur MAIL_SERVER=localhost dan MAIL_PORT=8025. Jika menggunakan Linux atau Mac OS, pembaca mungkin perlu menggunakan perintah sudo sehingga perintah tersebut bisa dijalankan. Jika menggunakan Windows, pastiakn membuka aplikasi cmd sebagai administrator. Hak akses admin diperlukan karena port di bawah 1024 adalah port yang hanya bisa dijalankan oleh administrator. Alternatif lain, kita bisa menggunakan port di atas 1024, misalnya 5025 dan atur variabel MAIL_PORT ke port tersebut di environment variable sehingga tidak memerlukan akses administrator.

Biarkan server SMTP berjalan lalu kembali ke terminal asal jalankan perintah export MAIL_SERVER=localhost dan MAIL_PORT=8025 (gunakan set sebagai ganti export jika menggunakan Microsoft Windows). Pastikan variabel FLASK_DEBUG sudah diatur menjadi 0 atau tidak diatur sama sekali, sehingga aplikasi tidak mengirim email dalam debug mode. Jalankan aplikasi dan picu error SQLAlchemy sekali lagi untuk melihat bahwa terminal yang menjalankan server email fake akan menampilkan sebuah pesan email yan gpenuh dengan kode-kode error.

Cara pengujian yang kedua untuk fitur ini ialah dengan menggunakan server email asli. Di bawah ini akan konfigurasi untuk akun server email Gmail:

export MAIL_SERVER=smtp.googlemail.com
export MAIL_PORT=587
export MAIL_USE_TLS=1
export MAIL_USERNAME=<your-gmail-username>
export MAIL_PASSWORD=<your-gmail-password>

Ingat, jika menggunakna Microsoft Windows, selalu gunakan set sebagai ganti export disetiap perintah di atas.

Fitur keamanan di akun GMail pembaca mungkin bisa mencegah aplikasi mengirim email sebelum kita atur akses "less secure apps" di *allow-*kan. Pelajari bagaimana melakukannya di sini, dan jika tidak ingin membuat akun Gmail utama menjadi kurang aman, gunakan email kedua untuk melakukan pengujian.

Menyimpan Log Kedalam File

Menerima error lewat email sangat membantu, tapi terkadang tidak cukup. Ada beberapa kesalahan yang tidak berakhir di exception Python sehingga tidak dianggap sebagai masalah penting, tapi masih berguna untuk tujuan debugging. Oleh karena itu, kita juga akan menyimpan sebuah file log untuk aplikasi ini.

Mengaktifkan log file, kita membutuhkan handler lain. Kali ini RotatingFileHandler perlu untuk ditambahkan ke application logger dengan cara yang tidak jauh berbeda dengan email handler.

app/init.py: Email configuration

# ...
from logging.handlers import RotatingFileHandler
import os

# ...

if not app.debug:
    # ...

    if not os.path.exists('logs'):
        os.mkdir('logs')
    file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240,
                                       backupCount=10)
    file_handler.setFormatter(logging.Formatter(
        '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler)

    app.logger.setLevel(logging.INFO)
    app.logger.info('Microblog startup')

Disini penulis membuat file log bernama microblog.lo di dalam direktori/folder logs, yang akan dibuatkan jika belum ada.

Kelas RotatingFileHandler sangat berguna karena merotasi file log, memastikan bahwa file log tidak berukuran terlalu besar saat aplikasi sudah berjalan cukup lama. Dalam kasus ini, penulis membatasi ukurannya menjadi 10KB, dan penulis menyimpan 10 file log terakhir untuk serep.

Kelas logging.Formatter memberikan formatting dalam penulisan pesan log. Karena pesan-pesan ini akan disimpan ke dalam sebuah file, maka kita akan menyimpan informasi sebanyak-banyaknya. Oleh karena itu, kita akan menggunakan format yang memiliki timestamp, logging level, isi pesan error dan nama file serta nomor baris yang menyebabkan sesuatu terjadi.

Agar proses logging bisa menangkap lebih banyak pesan, kita juga menurunkan logging level ke kategori INFO baik untuk application logger maupun file logger handler. Jika tidak familiar dengan kategori loggin, ada DEBUG, INFO, WARNING, ERROR, dan CRITICAL.

As a first interesting use of the log file, the server writes a line to the logs each time it starts. When this application runs on a production server, these log entries will tell you when the server was restarted.

Memperbaiki Bug Duplikasi Username

Sekarang saatnya kita untuk memperbaiki bug username yang sebelumnya telah ditemukan.

Jika pembaca ingat, RegistrationForm sudah mengimplementasi validasi untuk username, tapi validasi yang dilakukan di form edit agak berbeda. Saat registrasi, kita telah memastikan bahwa username yang dimasukkan belum ada di database. Di halaman edit profil kita juga harus melakukan pemeriksaan yang sama.

app/forms.py: Validate username in edit profile form.

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')

    def __init__(self, original_username, *args, **kwargs):
        super(EditProfileForm, self).__init__(*args, **kwargs)
        self.original_username = original_username

    def validate_username(self, username):
        if username.data != self.original_username:
            user = User.query.filter_by(username=self.username.data).first()
            if user is not None:
                raise ValidationError('Please use a different username.')

Dari method validasi di atas, kita bisa lihat bahwa username yang dikirim ke method validate_username() akan diperiksa dengan username asli yang ada di variabel self.original_username. Jika username yang dimasukkan sama dengan username asli, maka tidak perlu ada pemeriksaan duplikasi di database. Darimana datangnya original_username?

Username asli (sebelum dilakukan perubahan) dikirim melalui konstruktur yang artinya dikirim saat pembuatan objek form berlangsung.

Perhatikan kode di bawah ini. Kita mengirimkan username dari objek current_user saat membuat objek form.

app/routes.py: Validasi username di form edit profil.

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm(current_user.username)
    # ...

Sekarang bug sudah diperbaiki dan duplikasi username di form edit provil sudah bisa dicegah. Solusi ini bukanlah solusi paling sempurna, karena jika ada dua atau lebih proses yang mengakses database di waktu yang bersamaan, bisa jadi ada kasus siapa duluan yang berhasil mengubah database, maka proses setelahnya akan mencoba mengakses sesuatu yang saat ia periksa belum berubah tapi saat akan ditulis sudah berubah (semoga pembaca bisa memahaminya). Kasus ini umumnya bisa terjadi pada aplikasi yang sudah cukup besar dimana ada banyak user dan proses server yang aktif diwaktu yang bersamaan sehingga untuk saat ini kita tidak perlu mengkhawatirkannya.

Sampai di sini, pembaca bisa mencoba sekali lagi untuk menguji bagaimana method validasi form yang kita buat meng-handle duplikasi username.