Error Handling

Robust error handling is essential for production bioinformatics pipelines. BioLang provides try/catch, Result types, nil coalescing, and structured logging to handle errors gracefully at every level.

Try/Catch Basics

Catching file errors

# Basic try/catch for file operations
try {
  let data = read_vcf("data/variants.vcf")
  let cnt = data |> filter(|v| v.filter == "PASS") |> count
  print(f"PASS variants: {cnt}")
} catch e {
  print(f"Error reading VCF: {to_string(e)}")
  exit(1)
}

Catching specific error types

# Handle different error scenarios
try {
  let data = read_csv("data/expression.csv")
  let processed = data |> map(|row| {
    let val = float(row["score"])
    if val < 0 { error(f"Negative score at row {row}") }
    val
  })
  write_csv(processed, "output.csv")
} catch e {
  print(f"Error: {to_string(e)}")
  exit(1)
}

Try with finally

# Ensure cleanup happens regardless of success/failure
let tmp_dir = "tmp_pipeline"
mkdir(tmp_dir)

try {
  let reads = read_fastq("data/reads.fastq")
  reads
    |> filter(|r| mean_phred(r.quality) >= 30.0)
    |> write_fastq(f"{tmp_dir}/filtered.fq.gz")

  # Further processing...
  shell(f"samtools sort {tmp_dir}/aligned.bam")

  copy_file(f"{tmp_dir}/final.bam", "results/output.bam")
} catch e {
  print(f"Pipeline failed: {to_string(e)}")
}

# Clean up temp files
remove(tmp_dir)
print("Cleanup complete")

Result Chaining

Using Result type for composable error handling

# Functions can return result records
fn parse_score(text) {
  let val = try {
    float(text)
  } catch _ {
    nil
  }
  if val == nil {
    { ok: false, error: f"Cannot parse '{text}' as a number" }
  } else if val < 0.0 || val > 100.0 {
    { ok: false, error: f"Score {val} out of range [0, 100]" }
  } else {
    { ok: true, value: val }
  }
}

# Parse all rows
let results = read_csv("data/expression.csv")
  |> map(|row| parse_score(row["score"]))

# Handle mixed results
let successes = filter(results, |r| r.ok) |> map(|r| r.value)
let failures = filter(results, |r| !r.ok) |> map(|r| r.error)

print(f"Parsed: {len(successes)}, Errors: {len(failures)}")
if len(failures) > 0 {
  print(f"First error: {first(failures)}")
}

Unwrap with context

# Add context to error messages
fn load_reference(path) {
  try {
    fasta(path)
  } catch e {
    error(f"Failed to load reference genome at '{path}': {to_string(e)}")
  }
}

fn load_config(path) {
  try {
    let config = read_json(path)
    # Validate required fields
    let required = ["reference", "output_dir", "samples"]
    for field in required {
      if config[field] == nil {
        error(f"Config missing required field: '{field}'")
      }
    }
    config
  } catch e {
    error(f"Invalid config file '{path}': {to_string(e)}")
  }
}

# Usage with clear error messages
let config = load_config("pipeline.json")
let ref = load_reference(config["reference"])

Graceful Degradation

Fallback values

# Use ?? for nil fallbacks
let config = read_json("config.json")
let threads = config["threads"] ?? 4
let min_qual = config["min_quality"] ?? 30
let output = config["output_dir"] ?? "results"

print(f"Using {threads} threads, min quality {min_qual}")

# Chained fallbacks
let configured_ref = env("REF_GENOME") ?? config["reference"]
let ref_path = configured_ref ?? "/refs/default/GRCh38.fa"

print(f"Reference: {ref_path}")

Skipping bad records

# Process what you can, skip what you cannot
let skipped = 0
let processed = 0
let results = []

for row in read_csv("data/expression.csv") {
  try {
    let score = float(row["score"])
    let gene = trim(row["gene"])
    if gene == "" { error("Empty gene name") }
    processed = processed + 1
    push(results, { gene: gene, score: score })
  } catch _ {
    skipped = skipped + 1
  }
}

print(f"Processed: {processed}, Skipped: {skipped}")
write_tsv(from_records(results), "clean_results.tsv")

Retry logic

# Retry operations that may transiently fail (e.g., network)
fn with_retries(operation, max_attempts, delay_ms) {
  let last_error = nil
  for attempt in range(1, max_attempts + 1) {
    try {
      return operation()
    } catch e {
      last_error = e
      if attempt < max_attempts {
        print(f"Attempt {attempt} failed: {to_string(e)}. Retrying in {delay_ms}ms...")
        sleep(delay_ms)
      }
    }
  }
  error(f"All {max_attempts} attempts failed. Last error: {to_string(last_error)}")
}

# Use the retry helper with a named zero-argument function
fn fetch_gene_data() {
  download("https://api.example.com/data/genes.json", "genes.json")
  read_json("genes.json")
}
let data = with_retries(fetch_gene_data, 3, 2000)

Structured Logging

Log levels and output

# Simple structured logging via helper functions
fn log_msg(level, msg, ctx) {
  let line = f"[{now()}] [{level}] {msg}"
  if ctx != nil { line = f"{line} {to_string(ctx)}" }
  print(line)
  write_text(line, "pipeline.log")
}

log_msg("INFO", "Pipeline started", { samples: 24, reference: "GRCh38" })

for sample in samples {
  log_msg("INFO", "Processing sample", { sample: sample.name })

  try {
    # ... process ...
    log_msg("INFO", "Sample complete", { sample: sample.name, reads: cnt })
  } catch e {
    log_msg("ERROR", "Sample failed", { sample: sample.name, error: to_string(e) })
  }
}

log_msg("INFO", "Pipeline finished", {
  total: len(samples),
  succeeded: succeeded,
  failed: failed
})

Assertions

Defensive checks in pipelines

# Use assert for conditions that should never be false
let bam_records = read_bam("aligned.bam") |> collect
let total = len(bam_records)

# Sanity checks
assert total > 0, "BAM file has no reads"

let mapped = filter(bam_records, |r| int(r.flag) & 4 == 0)
let mapped_pct = float(len(mapped)) / float(total) * 100.0
assert mapped_pct > 50.0, f"Mapping rate too low: {round(mapped_pct, 1)}%"

# Assertions in data processing
let variants = read_vcf("data/variants.vcf")
  |> filter(|v| v.filter == "PASS")
  |> collect

assert len(variants) > 0, "No PASS variants found after filtering"

# Count transitions and transversions
let transitions = filter(variants, |v| is_snp(v) && (
  (v.ref == "A" && v.alt == "G") || (v.ref == "G" && v.alt == "A") ||
  (v.ref == "C" && v.alt == "T") || (v.ref == "T" && v.alt == "C")
))
let transversions = filter(variants, |v| is_snp(v) && !contains(["AG","GA","CT","TC"], f"{v.ref}{v.alt}"))
let ti_tv = float(len(transitions)) / float(len(transversions))
assert ti_tv > 1.5 && ti_tv < 4.0, f"Ti/Tv ratio {round(ti_tv, 2)} outside expected range [1.5, 4.0]"

Pre-condition and post-condition checks

# Validate inputs and outputs around critical steps
fn align_reads(r1, r2, ref, output) {
  # Pre-conditions
  assert file_exists(r1), f"R1 file not found: {r1}"
  assert file_exists(r2), f"R2 file not found: {r2}"
  assert file_exists(ref), f"Reference not found: {ref}"
  assert file_exists(f"{ref}.fai"), f"Reference index not found: {ref}.fai"

  # Do alignment
  shell(f"bwa-mem2 mem -t 8 {ref} {r1} {r2} > {output}")

  # Post-conditions
  assert file_exists(output), "Output BAM was not created"
  let size = file_size(output)
  assert size > 1000, f"Output BAM suspiciously small: {size} bytes"

  print(f"Alignment complete: {output} ({size} bytes)")
}