The Elegant Script Problem

We’ve all written them — beautiful, minimal scripts that work perfectly… until they don’t.

The problem? Assumptions are the mother of all screw-ups. Elegant scripts are built on a foundation of assumptions that the world will behave exactly as expected.

This post isn’t about Python being “bad” at error handling—it’s about how different error models affect the shape of small, real-world scripts as assumptions disappear.

Let’s take a real-world example: a script that downloads a PDF from an API.

Step 1: The Happy Path

Here’s what this looks like when we assume everything goes right:

Rye Version

Rye .args? .load .first :id
load %temp.cfg.rye |dict |to-context :setup

re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"

embed id "https://www.example.com/API-pdf?id={}&res=inv"
|to-uri .Request 'GET "" 
|Basic-auth! setup/token "x"
|Call :resp 
|Header? "Content-Disposition" |Submatch?* re
|to-file .Create
|Copy* Reader resp

Python Version

import sys, json, requests, re
from requests.auth import HTTPBasicAuth

id = int(sys.argv[1])

with open('temp.cfg.json') as f:
    setup = json.load(f)

pattern = re.compile(r"filename\*?=[f']?(.*?)[']?(?:;?$)")

url = f"https://www.example.com/API-pdf?id={id}&res=inv"
resp = requests.get(url, auth=HTTPBasicAuth(setup['token'], 'x'))

content_disp = resp.headers['Content-Disposition']
filename = pattern.search(content_disp).group(1)

with open(filename, 'wb') as f:
    f.write(resp.content)

Both are clean and roughly the same length. What are we assuming?

  • Script always gets exactly one integer argument
  • Config file exists and has correct content
  • HTTP request never fails
  • Content-Disposition header is always present
  • We can create a new file
  • File and network resources are cleaned up consistently across all failure paths

Step 2: Adding Validation

Let’s validate inputs and ensure we clean up resources properly.

Rye Version

Rye .args? .validate { 'strict one { integer } } 
|check "script argument id" |first :id

load %temp.cfg.rye |dict |validate>ctx { token: required string } 
|check "loading setup" :setup

re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"

embed id "https://www.example.com/API-pdf?id={}&res=inv"
|to-uri .Request 'GET "" 
|Basic-auth! setup/token "x"
|Call :resp 
|Header? "Content-Disposition" |Submatch?* re
|to-file .Create .defer\ 'Close
|Copy* resp .Reader .defer\ 'Close

We added .validate, validate>ctx, and .defer\ 'Close. The pipeline structure remains intact.

Python Version

import sys, json, requests, re
from requests.auth import HTTPBasicAuth

# Validate arguments
if len(sys.argv) != 2:
    raise ValueError("script argument id - expected exactly one integer")
try:
    id = int(sys.argv[1])
except ValueError:
    raise ValueError("script argument id - must be an integer")

# Load and validate config
with open('temp.cfg.json') as f:
    setup = json.load(f)

if 'token' not in setup or not isinstance(setup['token'], str):
    raise ValueError("loading setup - token field required as string")

pattern = re.compile(r"filename\*?=[f']?(.*?)[']?(?:;?$)")

url = f"https://www.example.com/API-pdf?id={id}&res=inv"
resp = requests.get(url, auth=HTTPBasicAuth(setup['token'], 'x'))

content_disp = resp.headers['Content-Disposition'] 
match = pattern.search(content_disp)
filename = match.group(1)

with open(filename, 'wb') as f:
    f.write(resp.content)

The Python version grows with length checks, try/except blocks, and if statements.


Step 3: Full Error Handling

Now let’s handle all failures gracefully with helpful error messages.

Rye Version

Rye .args? .validate { 'strict one { integer } }
|^check "script argument id" |first :id

load %temp.cfg.rye |check "couldn't open config" |dict
|validate>ctx { token: required string } |^check "loading setup" :setup

re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"

embed id "https://www.example.com/API-pdf?id={}&res=inv"
|to-uri .Request 'GET "" 
|Basic-auth! setup/token "x"
|Call |^check "Http request failed" :resp 
|Header? "Content-Disposition"
|Submatch?* re |fix { "default.pdf" }
|to-file .Create |^check "couldn't create local pdf" |defer\ 'Close
|Copy* resp .Reader .defer\ 'Close |^check "couldn't save contents"

Error handling is woven into the pipeline using ^check (exit on failure), fix (provide fallback), and defer\ (ensure cleanup). The happy path remains clearly visible.

Python Version

import sys, json, requests, re
from requests.auth import HTTPBasicAuth

# Validate arguments
if len(sys.argv) != 2:
    print("Error: script argument id - expected exactly one integer", file=sys.stderr)
    sys.exit(1)
try:
    id = int(sys.argv[1])
except ValueError:
    print("Error: script argument id - must be an integer", file=sys.stderr)
    sys.exit(1)

# Load and validate config
try:
    with open('temp.cfg.json') as f:
        setup = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
    print(f"Error: couldn't open config - {e}", file=sys.stderr)
    sys.exit(1)

if 'token' not in setup or not isinstance(setup['token'], str):
    print("Error: loading setup - token field required as string", file=sys.stderr)
    sys.exit(1)

pattern = re.compile(r"filename\*?=[f']?(.*?)[']?(?:;?$)")

url = f"https://www.example.com/API-pdf?id={id}&res=inv"

try:
    resp = requests.get(url, auth=HTTPBasicAuth(setup['token'], 'x'))
    resp.raise_for_status()
except requests.RequestException as e:
    print(f"Error: Http request failed - {e}", file=sys.stderr)
    sys.exit(1)

# Extract filename with default fallback
content_disp = resp.headers.get('Content-Disposition', '')
match = pattern.search(content_disp) if content_disp else None
filename = match.group(1) if match else "default.pdf"

try:
    with open(filename, 'wb') as f:
        f.write(resp.content)
except IOError as e:
    print(f"Error: couldn't create local pdf - {e}", file=sys.stderr)
    sys.exit(1)
except Exception as e:
    print(f"Error: couldn't save contents - {e}", file=sys.stderr)
    sys.exit(1)

What was a clean 15-line script has become a 45-line maze of try/except blocks.


The Numbers

Stage Rye Lines Python Lines Rye Max Nesting Python Max Nesting
Step 1 (Happy path) 11 15 0 1
Step 2 (Validation) 13 24 0 2
Step 3 (Full error handling) 14 45 0 2
Growth +27% +200% +1 level

The Rye version grew by 3 lines. The Python version tripled in size.


Two Approaches to Errors

Try-Catch: Errors as Interruptions

The try/except pattern treats errors as exceptional interruptions to normal flow. This pattern is ubiquitous — you’ll find it in Python, JavaScript, Java, C#, Ruby, PHP, and most mainstream languages.

The fundamental tension:

  • Clean code = ignoring potential errors
  • Robust code = wrapping everything in try-catch blocks

The visual structure changes dramatically. Linear logic becomes nested. The happy path gets buried.

Python can recover elegance with helper functions or decorators—but that elegance is constructed, not inherent to the control-flow model.

Rye Pipelines: Failures as Values

Rye treats failures as values that flow through the pipeline, just like successful results:

Function Purpose
check Prints message if failure, returns the failure
^check Prints message if failure, exits
fix Replaces failure with a fallback value
defer\ Schedules cleanup that runs even on failure

The pipeline structure stays intact. Error handling becomes just another transformation in the chain.


What About Rust?

Rust takes a similar “errors as values” philosophy with its Result and Option types. Functions return Result<T, E> instead of throwing exceptions, and you handle errors explicitly with pattern matching, ? operator, or combinators like map, and_then, and unwrap_or.

let content = fs::read_to_string("config.json")
    .map_err(|e| format!("couldn't open config: {}", e))?;

This approach works excellently in Rust, but it’s designed for a statically typed language where the compiler enforces that you handle every possible error case. The type system guides you.

Rye’s approach achieves similar ergonomics in a dynamically typed, scripting-friendly context. You get the pipeline clarity without needing type annotations or compile-time checks — failures simply propagate through the pipe until you handle them with check, fix, or let them bubble up.


Why This Matters

When error handling is verbose and disruptive:

  • Developers skip it
  • Code reviews miss coverage gaps
  • Maintenance becomes harder

When error handling is natural and inline:

  • Less friction to doing it right
  • Code remains readable
  • Happy path and error paths are visible together

Reducing assumptions is essential for production-ready code. But it shouldn’t mean exploding your elegant scripts into unreadable tangles.

The real question isn’t whether a language supports error handling—but whether it lets you remove assumptions without rewriting the story your code is telling.


Try Rye: ryelang.org