Artikel Testning · Felhantering

Undantaget som sväljs: när AI-genererad kod fångar felet och fortsätter

Be en modell om robust kod och du får ofta ett brett except eller catch runt det som kan gå fel, en tom retur och ingenting mer. Anropet ser lyckat ut, loggen är tyst och testerna är gröna — inte för att det fungerar, utan för att felet aldrig når något som kan observera det. Symtomet dyker upp veckor senare som saknade rader, uteblivna mejl eller ett saldo som inte stämmer.

Senast granskad: 20 aug 2026

Svartvit scen: till vänster en stor apliknande profil byggd av ett gitter av noder och tunna linjer. Till höger hänger en stor metallklocka i ett rep från en mörk bjälke. Ett tygstycke är lindat runt klockan och dras nedåt i en lång svepande rörelse, så att kläppen inuti är helt inbäddad i tyget. Golvet under är blankt och repigt.

Kortfattat

Ett sväljt undantag kan radera bevis i stället för att hantera felet. Ett except- eller catch-block är motiverat när felet är känt, kan hanteras just här och blocket bevarar en avsiktlig signal eller åtgärd genom loggning, omförsök eller vidarekastning. I Python betyder känt fel normalt en specifik undantagstyp; i JavaScript och TypeScript, där typen inte anges i catch-satsen, behöver try-blocket vara snävt och oväntade fel kastas vidare. Det finns ett smalt undantag: ett specifikt fel får ignoreras tyst när fortsatt körning uttryckligen är rätt beteende och ett test bevakar det. I övriga fall ska felet propagera. Att hitta blocken är sökarbete med regex; att avgöra vilka som får stanna kräver den här kontraktsbedömningen.

Samma fel i fyra skepnader

Mönstret ser olika ut i olika språk, men gör samma sak: det byter ett fel mot ett värde som ser normalt ut. Ett vanligt Python-mönster är en tom retur som passar in i en typsignatur som säger Optional:

def hamta_belopp(rad):
    try:
        return int(rad["belopp"])
    except Exception:
        return None

JavaScript-varianten behöver inte ens en retur. Ett tomt catch räcker, och blocket ser ut som en medveten markering av att felet är ointressant:

try {
  await skickaKvitto(order);
} catch (e) {
  // bara ett kvitto, inte kritiskt
}

I Go finns ingen undantagsmekanism, men ett returnerat fel kan tilldelas _, eller bara loggas på debug-nivå innan funktionen fortsätter. Anroparen kan då få samma normala resultat som om inget fel inträffat; om ett användbart spår finns kvar beror på loggkonfigurationen. I Java är den klassiska formen ett catch (Exception e) som anropar e.printStackTrace() och sedan låter metoden returnera som vanligt: spåret hamnar på standard error i en container ingen läser, medan anroparen får ett svar som betyder att allt gick bra.

MITRE har en egen post för precis det här. CWE-390, Detection of Error Condition Without Action, beskriver att produkten upptäcker ett specifikt fel men inte vidtar några åtgärder för att hantera det, och listar konsekvensen som oväntat tillstånd och förändrad exekveringslogik [1]. Den snävare CWE-1069, Empty Exception Block, gäller ett felhanteringsblock som inte innehåller någon kod alls [2]. Numren skiljer det allmänna passiva felblocket från det helt tomma; de säger inte hur vanligt mönstret är.

Risken blir särskilt värd att granska när instruktionen belönar kod som är "robust", "inte kraschar" eller "hanterar fel" utan att samtidigt ange vilket felkontrakt som ska gälla. Då kan en modell välja en normal retur i stället för att bevara felsignalen. Det är en felhypotes att testa, inte ett påstående om att alla modeller gör så. Pythons egen dokumentation rekommenderar att vara så specifik som möjligt med vilka undantagstyper man tänker hantera och att låta oväntade undantag propagera vidare [3].


Varför testsviten är grön ändå

Ett sväljt undantag gör två saker samtidigt, och det är den andra som är dyr. Den första är att felet försvinner ur loggen. Den andra är att funktionens kontrakt tyst byts ut: den lovade ett belopp eller ett fel, och levererar nu ett belopp eller None. Om anropande kod filtrerar bort tomma värden förvandlas felet till dataförlust.

Så här ser det ut mätt. Funktionen ovan kördes mot 1 000 rader där 47 var trasiga, antingen med tomt beloppsfält eller med fältet helt saknat. Loggningen stod på DEBUG, alltså den lägsta nivån, så ingenting var bortfiltrerat:

rader in: 1000 rader ut: 953 tappade: 47
summa: 476290

Fyrtiosju rader lämnade aldrig ett spår. Inte en rad i loggen, inget felmeddelande, och processen avslutades med exitkod 0. En avstämning som jämför summan mot källsystemet hittar felet — om någon gör en avstämning. Annars är den enda kvarvarande signalen att en siffra är för låg.

Testerna kan vara gröna av samma skäl. Ett test som utgår från samma otydliga krav som funktionen kan kontrollera att den inte kastar, eller att den returnerar None för trasig indata. Båda är sanna. Det är inte ett dåligt test i sig — det är ett test av fel kontrakt, och skillnaden syns först när man skriver ett test som kräver motsatsen.

Det här är också förklaringen till en obehaglig upplevelse längre fram: att felsöka något som inte lämnat några spår. Navsidan om felsökning börjar där felet är känt och ska förstås. Ett sväljt undantag ligger steget före — det gör att felsökningen aldrig kan börja, för det finns inget att läsa. När problemet upptäcks i drift blir jobbet i stället en rekonstruktion i efterhand, vilket är precis den situation AI-agenten som gjorde fel i produktion handlar om.


Sök fram dem: tre mönster och tre lintregler

Blocken går att hitta mekaniskt. Tre sökningar täcker de vanligaste formerna. Med ripgrep, där -U tillåter träffar över radbrytning och --pcre2 ger fullt regexstöd:

rg -U --pcre2 -g '*.py' 'except[^\n]*:\s*\n\s*pass\s*\n'
rg -U --pcre2 -g '*.py' 'except[^\n]*:\s*\n\s*return(\s+None)?\s*\n'
rg -U --pcre2 -g '*.py' 'except[^\n]*:\s*\n\s*continue\s*\n'

För JavaScript och TypeScript är motsvarigheten ett catch vars kropp är tom eller bara innehåller en kommentar:

rg -U --pcre2 -g '*.{js,ts,jsx,tsx}' 'catch\s*\([^)]*\)\s*\{\s*\}'
rg -U --pcre2 -g '*.{js,ts,jsx,tsx}' 'catch\s*\([^)]*\)\s*\{\s*(//[^\n]*\n\s*)+\}'

Räkna med brus, och räkna med mycket brus. Samma tre Python-sökningar kördes mot standardbiblioteket i Python 3.14.2 — 654 .py-filer, med site-packages och testpaketen bortsorterade — och gav 483 träffar på pass, 91 på return och 47 på continue, plus 154 fall av bara except: utan typ. Motsvarande sökning i 5 097 icke-minifierade .js-filer i ett installerat beroendeträd gav 81 tomma catch-block och 17 som bara innehöll en kommentar. Poängen med siffrorna är inte att standardbiblioteket är dåligt skrivet — många av träffarna är legitima — utan att en träff är en fråga, aldrig en dom. Kör sökningen på din diff först, inte på hela kodbasen, så blir mängden hanterbar.

Lintverktygen tar den återkommande delen. Tre regler räcker långt, och de gör tre olika saker:

E722 — bare-except. Fångar except: utan typ. Motiveringen i Ruffs dokumentation är att ett naket except fångar BaseException, vilket inkluderar KeyboardInterrupt och SystemExit, och att det både kan göra programmet svårt att avbryta och dölja andra problem [4].
BLE001 — blind-except. Ett steg strängare: den träffar except-satser som fångar alla undantag, inklusive både except BaseException och except Exception [5]. Den fångar alltså det breda Exception-mönstret i exemplet ovan, medan E722 inte gör det.
S110 — try-except-pass. Träffar själva sväljningen oavsett vilken typ som fångas. Regelns rekommendation är att logga undantaget i stället för att ignorera det, och exempelkoden i dokumentationen använder logging.exception [6].

I JavaScript-världen är motsvarigheten ESLint-regeln no-empty, som förbjuder tomma block. Läs villkoren noga innan du litar på den: regeln ignorerar block som innehåller en kommentar, och den har dessutom en option allowEmptyCatch som tillåter tomma catch-satser helt [7]. Ett projekt som satt den flaggan har en lintregel som ser aktiv ut i konfigurationen men inte fångar mönstret alls.

Kommentaren är inte hanteringen

Just undantaget för kommentarer gör varianten osynlig för verktyget. catch (e) { /* ignoreras avsiktligt */ } passerar no-empty i standarduppsättning. Kommentaren kan dokumentera ett legitimt beslut, men den skapar varken observabilitet eller en spärr mot att andra fel tystas. I JavaScript och TypeScript bör blocket därför skilja det förväntade felet från oväntade fel och kasta de senare vidare. Om fortsatt tyst körning verkligen är kontraktet ska ett test bevisa just det.


De tre villkoren för att få fånga ett undantag

Sökningen ger en lista. Listan behöver ett beslut per rad. De tre villkoren nedan ska normalt hålla samtidigt; avsteg för medveten tyst undertryckning kräver ett specifikt fel, ett uttryckligt kontrakt och ett test.

1
Känt fel. Fånga normalt en namngiven och specifik typ: FileNotFoundError, JSONDecodeError, TimeoutError. Ett except Exception kräver en tydlig gräns, till exempel ett toppnivåblock som loggar och kastar vidare; det ska inte användas för att välja en normal retur när felet är okänt. Pythons standardbibliotek har ett färdigt uttryck för det snäva fallet: contextlib.suppress returnerar en kontexthanterare som undertrycker de angivna undantagen, med den uttryckliga reservationen i dokumentationen att den, som varje mekanism som helt undertrycker undantag, bara ska användas för mycket specifika fel där det är känt att det är rätt att fortsätta tyst [8].
2
Hanterbart just här. Frågan är inte om felet kan hanteras någonstans, utan om den här funktionen har den information som krävs. En parser som läser en rad vet inte om en trasig rad ska hoppas över eller stoppa hela importen — det vet importjobbet. Fångar du felet i parsern har du fattat importjobbets beslut åt det, utan att veta vad beslutet var. En fallgrop värd att känna till: både try-blocket och contextlib.suppress avbryter hela blocket vid felet, så koden efter den felande raden inne i blocket körs aldrig [8]. Ett brett try runt fem satser är därför inte bara för brett i typ, utan också i omfång.
3
Kontraktet bevaras. Ett block som fångar ska normalt logga med bevarad stacktrace, göra ett omförsök eller kasta vidare ett fel som betyder något för anroparen. Medveten tyst undertryckning är det snäva fjärde fallet som beskrivs under villkor 1. I Python loggar logging.exception på nivån ERROR med undantagsinformationen tillagd, och dokumentationen anger att den bara ska anropas från en undantagshanterare [9]. Ett logging.error(str(e)) är inte samma sak — det ger felmeddelandet utan stacktrace, alltså raden men inte vägen dit. Vill du kasta vidare något annat: använd raise NyttFel(...) from exc, så att kedjan pekar ut det ursprungliga felet i stället för att dölja det [3].

Villkor tre är också gränsen mot ett angränsande ämne. Ett omförsök är en giltig åtgärd, men bara för fel som är tillfälliga — en timeout, en 503, en avbruten anslutning. Vilka fel som får försökas igen, och hur backoff och idempotens hänger ihop med det, hör till API-integrationer med AI och upprepas inte här. Det som gäller i det här sammanhanget är enklare: ett omförsök som ger upp tyst är fortfarande ett sväljt fel.


Testet som failar när felet tystas

Ett fel som ska propagera är ett kontrakt, och kontrakt kan testas. Testet nedan använder bara standardbiblioteket och kör som det står. Poängen med formen är att exakt samma två testfall körs mot båda varianterna av funktionen, så att skillnaden inte kan bortförklaras med att testerna är olika:

import unittest

class Konto:
    def __init__(self): self.rader = []
    def bokfor(self, belopp):
        if belopp < 0:
            raise ValueError("negativt belopp")
        self.rader.append(belopp)

def svalt(konto, belopp):          # monstret modellen skriver
    try:
        konto.bokfor(belopp)
    except Exception:
        return None
    return True

def propagerande(konto, belopp):   # felet far ga vidare
    konto.bokfor(belopp)
    return True

class Kontrakt:
    funktion = None

    def test_felet_bubblar_upp(self):
        with self.assertRaises(ValueError):
            self.funktion(Konto(), -5)

    def test_ingen_rad_bokfordes(self):
        konto = Konto()
        try:
            self.funktion(konto, -5)
        except ValueError:
            pass
        self.assertEqual(konto.rader, [])

class TestSvalt(Kontrakt, unittest.TestCase):
    funktion = staticmethod(svalt)

class TestPropagerande(Kontrakt, unittest.TestCase):
    funktion = staticmethod(propagerande)

if __name__ == "__main__":
    unittest.main(verbosity=2)

Körd på Python 3.14.2 under Windows 11 ger den fyra testfall, ett misslyckande och exitkod 1:

test_felet_bubblar_upp (TestPropagerande...) ... ok
test_ingen_rad_bokfordes (TestPropagerande...) ... ok
test_felet_bubblar_upp (TestSvalt...) ... FAIL
test_ingen_rad_bokfordes (TestSvalt...) ... ok

AssertionError: ValueError not raised
Ran 4 tests in 0.001s
FAILED (failures=1)

Det intressanta är inte att ett test failar, utan vilket. Kontrollen att ingen rad bokfördes passerar för båda varianterna — den sväljande funktionen skrev ju heller ingen rad. Ett test som bara kontrollerar att biverkan uteblev bevakar alltså ingenting alls här. Det är bara assertRaises som skiljer en funktion som stoppade felet från en som gömde det.

Använder du pytest är motsvarigheten pytest.raises som kontexthanterare, med match= för att dessutom kräva att felmeddelandet stämmer; match jämförs med re.search(), så en delsträng räcker [10]. Att kräva rätt meddelande är värt besväret när koden kastar samma undantagstyp av flera olika skäl.

Skriv testet mot den ofixade koden

Kör kontraktstestet mot det sväljande blocket innan du rättar det, och se det falla med rätt felmeddelande. Ser du testet bara passera efter åtgärden vet du inte om det hade fångat den ursprungliga buggen. Avvägningen är att ett kontraktstest per felväg kostar rader i testsviten: skriv det för de fel som får konsekvenser i data eller pengar, inte för varenda try i kodbasen.


Fem frågor att ställa till diffen

Felhantering är en av de sju frågorna per diff i guiden om kodgranskning. De fem frågorna här är fördjupningen bakom just den frågan, och de går att ställa utan att köra något.

Vilken undantagstyp fångas? Står det Exception, BaseException eller inget alls krävs en uttrycklig motivering till varför blocket ligger vid en gräns och hur oväntade fel bevaras. Fråga vilket konkret fel blocket finns för.
Vad returneras i felfallet? None, tom lista eller false betyder att felet blivit ett giltigt värde. Följ värdet till anroparen och se vad som händer med det där.
Finns stacktrace i loggen? logging.exception eller logger.error(..., exc_info=True) ger vägen; logging.error(str(e)) ger bara raden. Kontrollera också att den valda nivån faktiskt samlas in och går att larma på i drift; en DEBUG-rad är värdelös om driftmiljön filtrerar bort den.
Hur många satser ligger inne i try-blocket? Varje extra sats breddar mängden fel som blocket kan fånga. Kontrollera att omfånget inte täcker operationer vars fel ska hanteras på ett annat sätt — och kom ihåg att satser efter den felande raden i blocket aldrig körs.
Finns ett test som kräver rätt felkontrakt? När felet ska propagera ska testet kräva det. När ett specifikt fel medvetet undertrycks ska testet i stället kräva den avsedda fortsättningen. Utan ett sådant kontrakt finns ingen spärr mot att blocket blir bredare nästa gång funktionen skrivs om.

Källor

[1] MITRE, CWE-390: Detection of Error Condition Without Action — produkten upptäcker ett specifikt fel men vidtar inga åtgärder för att hantera det. [2] MITRE, CWE-1069: Empty Exception Block — ett felhanteringsblock som inte innehåller någon kod. [3] Python-dokumentationen, Errors and Exceptions — god sed att vara så specifik som möjligt med hanterade undantagstyper och låta oväntade undantag propagera; raise ... from för att markera att ett fel är en direkt följd av ett annat. [4] Ruff, E722 bare-except — ett naket except fångar BaseException och kan dölja andra problem. [5] Ruff, BLE001 blind-except — träffar except-satser som fångar alla undantag, inklusive except Exception. [6] Ruff, S110 try-except-pass — mönstret undertrycker alla undantag; rekommendationen är att logga i stället för att ignorera. [7] ESLint, no-empty — förbjuder tomma block, ignorerar block som innehåller en kommentar och har optionen allowEmptyCatch. [8] Python-dokumentationen, contextlib.suppress — undertrycker angivna undantag och återupptar körningen efter with-satsen; ska bara användas för mycket specifika fel där det är känt att det är rätt att fortsätta tyst. [9] Python-dokumentationen, logging.exception — loggar på nivån ERROR med undantagsinformation tillagd och ska bara anropas från en undantagshanterare. [10] pytest, Assertions about expected exceptionspytest.raises som kontexthanterare och match= som jämförs med re.search(). Samtliga källor lästa 20 augusti 2026. Mätvärdena i texten kommer från körningar under arbetet med artikeln på Python 3.14.2 under Windows 11, med ripgrep 14.1.1 för sökmönstren. De är exempel på storleksordning i den miljön, inte allmängiltiga tal.