Operasi I/O di dunia nyata tidak hanya gagal — kadang ia diam saja: server tidak menjawab, socket menggantung, dan program kita ikut menggantung bersamanya. Program async yang sehat harus bisa membatalkan task yang terlalu lama dan membatik batas waktu dengan rapi. Chapter ini membahas mekanisme pembatalan asyncio: CancelledError, Task.cancel(), asyncio.wait_for(), dan context manager asyncio.timeout().

CancelledError Bukan Exception Biasa #

Di asyncio, pembatalan dikirim sebagai exception — tetapi bukan Exception. Sejak Python 3.8, asyncio.CancelledError mewarisi langsung BaseException, satu level di atas Exception, tepat seperti KeyboardInterrupt:

python
import asyncio

print(asyncio.CancelledError.__mro__)
print(issubclass(asyncio.CancelledError, Exception))

Output yang Dihasilkan:

text
(<class 'asyncio.exceptions.CancelledError'>, <class 'BaseException'>, <class 'object'>)
False

Hierarki ini sengaja dibuat demikian agar except Exception: yang umum dipakai untuk menangani error aplikasi tidak ikut menelan sinyal pembatalan. Konsekuensinya, issubclass(asyncio.CancelledError, Exception) bernilai False.

Membatalkan Task dengan cancel() #

Task.cancel() meminta event loop menaikkan CancelledError di titik await berikutnya di dalam task tersebut. Coroutine yang baik memanfaatkan except untuk pembersihan dan finally untuk penutupan resource:

python
import asyncio

async def proses_pesanan():
    try:
        print("proses dimulai")
        await asyncio.sleep(60)
        print("selesai diproses")
    except asyncio.CancelledError:
        print("dibatalkan: mengembalikan stok")
        raise
    finally:
        print("finally: mencatat ke log")

async def main():
    tugas = asyncio.create_task(proses_pesanan())
    await asyncio.sleep(0.5)
    tugas.cancel()
    try:
        await tugas
    except asyncio.CancelledError:
        print("main menangkap CancelledError")

asyncio.run(main())

Output yang Dihasilkan:

text
proses dimulai
dibatalkan: mengembalikan stok
finally: mencatat ke log
main menangkap CancelledError

Mari kita telaah apa yang terjadi pada baris-baris kode di atas:

  1. Baris 4 (try:): seluruh badan pekerjaan dibungkus supaya pembatalan bisa ditangani di tengah jalan, bukan menggugurkan task tanpa pembersihan.
  2. Baris 6 (await asyncio.sleep(60)): titik tunggu. main membatalkan task pada detik ke-0.5, jadi sleep tidak pernah selesai dan baris 7 tidak pernah tercetak.
  3. Baris 8 (except asyncio.CancelledError:): menangkap sinyal pembatalan, menjalankan pembersihan domain (mengembalikan stok), lalu raise pada baris 10 menaikkan ulang exception yang sama.
  4. Baris 11 (finally:): dijalankan setelah blok except, bahkan saat exception sedang merambat — di sinilah tempat menutup koneksi, menghentikan progress bar, atau mencatat log.
  5. Baris 17 (tugas.cancel()): permintaan pembatalan; exception baru diterbangkan ke titik await terdekat di dalam task.

Timeout dengan wait_for #

asyncio.wait_for(coro, timeout=n) membungkus coroutine dengan pembatas waktu: jika melebihi batas, coroutine dibatalkan (ia menerima CancelledError), lalu pemanggil menerima TimeoutError. Kedua exception tersebut terlihat di satu program:

python
import asyncio
import time

async def unduh_lambat():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("unduh_lambat menerima CancelledError")
        raise

async def main():
    mulai = time.perf_counter()
    try:
        await asyncio.wait_for(unduh_lambat(), timeout=1)
    except TimeoutError:
        print(f"TimeoutError ditangkap setelah {time.perf_counter() - mulai:.1f} detik")

asyncio.run(main())

Output yang Dihasilkan (nilai waktu dapat sedikit berbeda di mesin Anda):

text
unduh_lambat menerima CancelledError
TimeoutError ditangkap setelah 1.0 detik

Alur lengkapnya: pada detik ke-1, wait_for membatalkan unduh_lambat; coroutine tersebut menerima CancelledError di sleep-nya, menaikkan ulang, lalu wait_for menerjemahkannya menjadi TimeoutError bagi main. Sejak Python 3.11, asyncio.TimeoutError adalah nama lain untuk TimeoutError bawaan, jadi except TimeoutError: menangkap keduanya.

Context Manager asyncio.timeout #

Untuk membatasi banyak operasi sekaligus dengan satu batas waktu, context manager asyncio.timeout() (Python 3.11+) lebih rapi — semua await di dalam blok berbagi jam yang sama:

python
import asyncio

async def main():
    try:
        async with asyncio.timeout(1):
            await asyncio.sleep(10)
            print("tidak pernah tercetak")
    except TimeoutError:
        print("blok dibatalkan setelah 1 detik")

asyncio.run(main())

Output yang Dihasilkan:

text
blok dibatalkan setelah 1 detik

Saat asyncio.sleep(10) melewati batas 1 detik, seluruh blok async with dibatalkan dan TimeoutError keluar dari blok — baris print("tidak pernah tercetak") tidak pernah tereksekusi. Pola try/finally di dalam coroutine tetap bekerja seperti biasa, sehingga pembersihan resource dari Chapter Async Context Managers tetap dijamin berjalan saat timeout terjadi.

Dengan pembatalan dan timeout di tangan, kita siap menghadapi sumber data yang paling sering menggantung itu sendiri: jaringan, pada Chapter Async Network I/O.