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