Subsections of Day 1: Intro to Java
Why Java?
Java compiles to bytecode , which runs on the JVM — the same .class file works on
Linux, macOS and Windows. A new release ships every six months, an LTS release every two years.
Version
Year
Highlights
8
2014
lambdas, streams, Optional, java.time
11 LTS
2018
HTTP client
17 LTS
2021
records, sealed classes, text blocks, switch expressions
21 LTS
2023
virtual threads, pattern matching for switch
25 LTS
2025
compact source files & IO, module imports, scoped values
Where it is used
Backends (Spring Boot, Quarkus), Android, big data (Kafka, Spark, Elasticsearch), and most
developer tooling.
Java ≠ JavaScript
Unrelated languages. The name was a 1995 marketing decision.
Three acronyms
JDK — compiler, JShell, tools (what you install)
JRE — JVM + standard library
JVM — runs bytecode, compiles hot code to machine code (JIT), collects garbage
You never free memory manually.
Static typing
Types are checked at compile time:
int age = 30 ;
age = "thirty" ; // compile error
var infers the type — it does not make Java dynamic:
var age = 30 ; // int
var names = new ArrayList < String > (); // ArrayList < String >
Conventions
Element
Style
Example
classes, records, interfaces
UpperCamelCase
BankAccount
methods, variables
lowerCamelCase
computeTotal
constants
UPPER_SNAKE_CASE
MAX_SIZE
packages
all lowercase
com.example.shop
file name
= public class name
BankAccount.java
Four spaces of indentation, opening brace on the same line.
class Account {
private final String owner ;
Account ( String owner ) {
this . owner = owner ;
}
}
// line
/* block */
/**
* Javadoc — rendered to HTML docs.
* @param amount deposit in cents
* @return the new balance
*/
What Java does not have
free functions (every method belongs to a type)
multiple class inheritance (interfaces instead)
pointer arithmetic or manual memory management
operator overloading (+ on strings is the one exception)
★ Exercises
Which Java version is active on your machine? Is it an LTS?
Rewrite in proper Java style: max retry count (constant), user profile (class),
load data (method).
Explain JDK vs JRE vs JVM in your own words.
Why does var x; not compile? Try it in JShell and read the error.
Basic Data Types
Variables
int count = 42 ;
double price = 19 . 99 ;
boolean active = true ;
String name = "Ann" ;
var count2 = 42 ; // same thing, type inferred
final double VAT = 0 . 19 ; // constant
var works for local variables only — not fields, parameters or return types.
Tip
Make everything final that never changes. final var works too.
The eight primitives
Type
Size
Use
Default
byte
8 bit
−128…127
0
short
16 bit
−32,768…32,767
0
int
32 bit
default integer type
0
long
64 bit
big integers, suffix L
0L
float
32 bit
suffix f
0.0f
double
64 bit
default decimal type
0.0
char
16 bit
one character, single quotes
'�'
boolean
–
true / false
false
long population = 8_500_000_000L ; // underscores for readability
char initial = 'A' ;
Arithmetic
7 / 2 // 3 ← integer division!
7 % 2 // 1
7 / 2 . 0 // 3.5
int max = Integer . MAX_VALUE ;
max + 1 // overflows silently
Math . addExact ( max , 1 ) // throws ArithmeticException
0 . 1 + 0 . 2 // 0 . 30000000000000004
For money use BigDecimal, or count cents in a long:
new BigDecimal ( "0.1" ). add ( new BigDecimal ( "0.2" )) // 0 . 3
var n = 0 ;
n += 5 ; n -= 2 ; n *= 4 ; n ++ ; n -- ;
Wrappers and autoboxing
Every primitive has an object twin: Integer, Long, Double, Boolean, Character.
Collections need those, and conversion is automatic.
List < Integer > nums = new ArrayList <> ();
nums . add ( 42 ); // int → Integer
int first = nums . get ( 0 );
Warning
Wrappers can be null. Integer x = null; int y = x; throws a NullPointerException.
Strings
Strings are immutable — every method returns a new one.
var text = "Hello World" ;
text . length () // 11
text . toUpperCase ()
text . charAt ( 0 ) // 'H'
text . substring ( 6 ) // "World"
text . contains ( "World" ) // true
text . indexOf ( "World" ) // 6
text . replace ( "World" , "Java" )
text . split ( " " ) // ["Hello", "World"]
" edge " . strip ()
" " . isBlank () // true
"ab" . repeat ( 3 ) // "ababab"
Building strings
var s1 = name + " is " + age ;
var s2 = "%s is %d" . formatted ( name , age );
var s3 = String . join ( ", " , "red" , "green" , "blue" );
Format
Meaning
%s
any value as text
%d
integer, %05d → 00042
%.2f
two decimals
%n
newline
Comparing strings
a == b // reference identity — almost never what you want
a . equals ( b ) // content
a . equalsIgnoreCase ( b )
The classic beginner bug
Use equals for objects, == only for primitives and enum constants.
Text blocks
var json = """
{
"name": "Ann",
"role": "developer"
}
""" ;
Indentation is stripped relative to the closing """; no escaping of quotes needed.
StringBuilder
var sb = new StringBuilder ();
for ( var i = 1 ; i <= 5 ; i ++ ) sb . append ( i ). append ( " " );
sb . toString (). strip (); // "1 2 3 4 5"
void main () {
var name = IO . readln ( "Your name? " );
IO . println ( "Hello, " + name + "!" );
}
The classic way still works: System.out.println(...), System.err.println(...).
★ Exercises
Store your name, birth year and height in suitable types and print one sentence.
Compute your age in days (365 per year is fine).
Write three versions of 5 / 2 that yield 2.5.
Split "ann,miller,berlin" and print each part capitalized on its own line.
Build a small HTML page with a text block and print it.
Why is 0.1 + 0.2 == 0.3 false? How would you compare instead?
Methods
Return type, name, parameters, body:
int add ( int a , int b ) {
return a + b ;
}
void greet ( String name ) { // void = returns nothing
IO . println ( "Hello, " + name );
}
Arguments are copies
Java is always pass-by-value. For objects the reference is copied — you can mutate the
object, but not reassign the caller’s variable.
void fill ( List < String > list ) {
list . add ( "new" ); // visible to the caller
list = new ArrayList <> (); // local only
}
Overloading
Same name, different parameter lists:
int area ( int side ) { return side * side ; }
int area ( int width , int height ) { return width * height ; }
double area ( double radius ) { return Math . PI * radius * radius ; }
A different return type alone is not enough.
Varargs
int sum ( int ... numbers ) {
var total = 0 ;
for ( var n : numbers ) total += n ;
return total ;
}
sum (); // 0
sum ( 1 , 2 , 3 ); // 6
Must be the last parameter.
Scope
A variable lives inside its block:
void demo () {
var outer = 1 ;
if ( outer > 0 ) {
var inner = 2 ;
}
IO . println ( inner ); // error: does not exist here
}
static methods belong to the class, not an instance:
public class MathHelper {
public static int square ( int x ) { return x * x ; }
}
MathHelper . square ( 5 );
Writing good methods
one job per method, and the name says it — if you need “and”, split it
verbs first: computeDiscount, loadCustomer, isValid
more than three parameters? use a record instead
return early instead of nesting
String grade ( int points ) {
if ( points < 0 || points > 100 ) return "invalid" ;
return points >= 50 ? "pass" : "fail" ;
}
Recursion
long factorial ( int n ) {
if ( n <= 1 ) return 1 ; // base case
return n * factorial ( n - 1 );
}
Java has no tail-call optimisation — deep recursion throws StackOverflowError.
★ Exercises
isPalindrome(String) — ignore case.
Overload max three ways: two int, two double, any number of int.
countVowels(String).
Write Fibonacci recursively and iteratively; time both for n = 40 with System.nanoTime().
formatName(String first, String last) → "Miller, Ann", tolerating stray spaces and
mixed case.
Arrays & Collections
Arrays
Fixed length, zero-based:
int [] numbers = new int [ 5 ] ;
String [] colors = { "red" , "green" , "blue" };
colors . length // 3 — a field, not a method
colors [ 1 ] // "green"
colors [ 3 ] // ArrayIndexOutOfBoundsException
Arrays . sort ( numbers );
Arrays . toString ( numbers );
Arrays are rigid. In practice you use a List.
List
Ordered, resizable, duplicates allowed:
var names = new ArrayList < String > ();
names . add ( "Ann" );
names . add ( "Ben" );
names . get ( 0 ) // "Ann"
names . size () // 2
names . contains ( "Ben" ) // true
names . indexOf ( "Ben" ) // 1
names . remove ( "Ben" );
names . set ( 0 , "Anne" );
names . isEmpty ()
List<String> means “list of strings” — the compiler keeps everything else out. On the right
side the diamond <> is enough: List<String> names = new ArrayList<>();
var days = List . of ( "Mon" , "Tue" , "Wed" ); // immutable
days . add ( "Thu" ); // UnsupportedOperationException
var copy = new ArrayList <> ( days ); // mutable copy
Tip
Default to List.of(...). Immutable is the common case and prevents accidental changes.
for ( var name : names ) IO . println ( name );
for ( var i = 0 ; i < names . size (); i ++ ) IO . println ( i + ": " + names . get ( i ));
names . forEach ( IO :: println );
Set
No duplicates. HashSet (unordered), LinkedHashSet (insertion order), TreeSet (sorted).
var tags = new HashSet < String > ();
tags . add ( "java" );
tags . add ( "java" ); // no effect
tags . size () // 1
var a = Set . of ( 1 , 2 , 3 , 4 );
var b = Set . of ( 3 , 4 , 5 );
var intersection = new HashSet <> ( a ); intersection . retainAll ( b ); // [3, 4]
var union = new HashSet <> ( a ); union . addAll ( b ); // [1..5]
var difference = new HashSet <> ( a ); difference . removeAll ( b ); // [ 1 , 2 ]
Map
Keys → values, keys are unique.
var ages = new HashMap < String , Integer > ();
ages . put ( "Ann" , 30 );
ages . put ( "Ann" , 31 ); // overwrites
ages . get ( "Ann" ) // 31
ages . get ( "Zoe" ) // null
ages . getOrDefault ( "Zoe" , 0 ) // 0
ages . containsKey ( "Ann" )
ages . remove ( "Ann" );
Useful patterns:
counts . merge ( "java" , 1 , Integer :: sum ); // count things
counts . putIfAbsent ( "course" , 1 );
groups . computeIfAbsent ( "a" , k -> new ArrayList <> ()). add ( "Ann" );
for ( var e : ages . entrySet ()) IO . println ( e . getKey () + " " + e . getValue ());
ages . forEach (( name , age ) -> IO . println ( name + ": " + age ));
var capitals = Map . of ( "Germany" , "Berlin" , "France" , "Paris" ); // immutable
Which one?
Need
Type
ordered, duplicates ok
ArrayList
no duplicates, fast lookup
HashSet
no duplicates, sorted
TreeSet
key → value
HashMap
key → value, sorted
TreeMap
queue / stack
ArrayDeque
Records: bundling data
When values belong together, use a type — not a map:
record Person ( String name , int age ) {}
var ann = new Person ( "Ann" , 30 );
ann . name () // "Ann"
IO . println ( ann ) // Person [ name = Ann , age = 30 ]
You get the constructor, accessors, equals, hashCode and toString for free — which also
makes records good map keys:
record Coordinate ( int x , int y ) {}
var map = new HashMap < Coordinate , String > ();
map . put ( new Coordinate ( 1 , 2 ), "treasure" );
map . get ( new Coordinate ( 1 , 2 )); // "treasure" — different object , equal content
More on records in chapter 11 .
★ Exercises
Sum, average, min and max of ten numbers, using a loop.
Remove duplicates from a list — once with a Set, once by hand.
Count letter frequency of a sentence in a Map<Character, Integer>.
Build a phone book Map<String, String> with add, lookup and delete methods.
Define record Item(String name, double price, int qty) and total a cart of five items.
Turn a List<String> into a Map<Integer, List<String>> grouped by word length.
Boolean Logic
There is no “truthy”
A condition must be a boolean. Nothing else.
if ( text ) { … } // error
if ( ! text . isEmpty ()) { … } // ok
if ( count ) { … } // error
if ( count != 0 ) { … } // ok
Comparison
Operator
Meaning
== !=
equal / not equal (identity for objects)
< > <= >=
ordering
a . equals ( b ) // content comparison
Objects . equals ( a , b ) // null - safe on both sides
The wrapper trap — small values are cached:
Integer x = 127 , y = 127 ;
Integer p = 128 , q = 128 ;
x == y // true (cache −128…127)
p == q // false
p . equals ( q ) // true
Logical operators
age >= 18 && hasId // and
age < 18 || hasId // or
! hasId // not
&& and || short-circuit — the right side is skipped when the result is already known.
That protects you:
if ( name != null && name . length () > 3 ) { … } // safe
if ( name != null & name . length () > 3 ) { … } // NPE : & always evaluates both
a
b
a && b
a || b
!a
true
true
true
true
false
true
false
false
true
false
false
true
false
true
true
false
false
false
false
true
Ternary
var status = points >= 50 ? "pass" : "fail" ;
Fine for simple cases, never nest it — use switch instead.
Dealing with null
String name = null ;
name . length (); // NullPointerException
Java tells you exactly what was null:
Cannot invoke "String.length()" because "name" is null
Strategies:
if ( name != null && ! name . isBlank ()) { … } // check
Objects . requireNonNullElse ( name , "unknown" ); // fallback
Objects . requireNonNull ( name , "name is required" ); // fail fast
Optional < String > found = find ( "Ann" ); // see chapter 10
Tip
Never return null for a collection — return an empty one. It saves your callers hundreds
of null checks.
Keep conditions readable
var isAdult = c . age () >= 18 ;
var isDomestic = c . country (). equals ( "US" );
var isActive = ! c . blocked () && c . balance () > 0 ;
if ( isAdult && isDomestic && isActive ) { … }
De Morgan: !(a && b) ≡ !a || !b, and !(a || b) ≡ !a && !b.
★ Exercises
boolean isLeapYear(int year) — divisible by 4, not by 100, unless by 400.
Why is "a" == "a" often true, yet unreliable? Try it in JShell.
Rewrite !(age < 18 || blocked) without the leading !.
boolean isValidPassword(String) — 8+ chars, a digit, an uppercase letter; null is
invalid but must not throw.
What does 1 == 1.0 print, and why?
Loops & Control Flow
if / else
if ( temp > 30 ) {
IO . println ( "hot" );
} else if ( temp > 15 ) {
IO . println ( "mild" );
} else {
IO . println ( "cold" );
}
Braces are optional for a single statement — always write them anyway.
switch
Old form, needs break or it falls through:
switch ( day ) {
case 1 :
case 7 :
IO . println ( "weekend" );
break ;
default :
IO . println ( "weekday" );
}
Arrow form — no fallthrough, and it produces a value:
var label = switch ( day ) {
case 1 , 7 -> "weekend" ;
case 2 , 3 , 4 , 5 , 6 -> "weekday" ;
default -> throw new IllegalArgumentException ( "bad day: " + day );
};
var text = switch ( grade ) {
case 1 -> "excellent" ;
default -> {
var s = "grade " + grade ;
yield s . toUpperCase (); // yield inside a block
}
};
Pattern matching
String describe ( Object o ) {
return switch ( o ) {
case Integer i when i > 100 -> "big number: " + i ;
case Integer i -> "number: " + i ;
case String s -> "text of %d chars" . formatted ( s . length ());
case int [] a -> "array of " + a . length ;
case null -> "nothing" ;
default -> "unknown" ;
};
}
when adds a guard. Without case null, a null value throws.
Record patterns
sealed interface Shape permits Circle , Rect {}
record Circle ( double radius ) implements Shape {}
record Rect ( double w , double h ) implements Shape {}
double area ( Shape s ) {
return switch ( s ) {
case Circle ( double r ) -> Math . PI * r * r ;
case Rect ( double w , double h ) -> w * h ;
};
}
Note
No default needed: because Shape is sealed, the compiler knows every case — and will
flag this switch if you add another shape later.
instanceof with a pattern
if ( o instanceof String s && s . length () > 3 ) {
IO . println ( s . toUpperCase ());
}
Loops
for ( var name : names ) { … } // for-each: the default
for ( var i = 0 ; i < 5 ; i ++ ) { … } // when you need the index
for ( var i = 10 ; i > 0 ; i -= 2 ) { … }
while ( rest > 1 ) { rest /= 2 ; }
do { answer = IO . readln ( "again? " ); } // runs at least once
while ( ! answer . equals ( "n" ));
break and continue
for ( var n : numbers ) {
if ( n % 2 != 0 ) continue ; // skip
if ( n > 15 ) break ; // leave the loop
IO . println ( n );
}
Labels exist for nested loops, but extracting a method and return is usually cleaner:
outer :
for ( … ) { for ( … ) { if ( … ) break outer ; } }
Putting it together
void main () {
var secret = new java . util . Random (). nextInt ( 1 , 101 );
var tries = 0 ;
while ( true ) {
var input = IO . readln ( "Guess 1-100: " );
int guess ;
try {
guess = Integer . parseInt ( input . strip ());
} catch ( NumberFormatException e ) {
IO . println ( "Not a number." );
continue ;
}
tries ++ ;
switch ( Integer . compare ( guess , secret )) {
case - 1 -> IO . println ( "too low" );
case 1 -> IO . println ( "too high" );
default -> {
IO . println ( "Got it in %d tries!" . formatted ( tries ));
return ;
}
}
}
}
★ Exercises
Print a multiplication table from 1 to 10, aligned with "%4d".
FizzBuzz for 1–100 — once with if/else, once with a switch expression.
Print all primes up to n.
Write String classify(Object o) handling Integer, Double, String, List and null.
Add a Triangle to the shapes above. What does the compiler say about your old switch?
Change the guessing game to stop after 7 wrong guesses.
Working With Java Programs
The classic structure
public class Calculator {
public static void main ( String [] args ) {
System . out . println ( "start" );
}
}
public class Calculator — file must be Calculator.java
static — the JVM can call it without an object
String[] args — command line arguments
The compact form (Java 25) does the same thing:
void main () {
IO . println ( "start" );
}
Both may contain fields and methods:
final double VAT = 0 . 19 ;
double gross ( double net ) { return net * ( 1 + VAT ); }
void main () { IO . println ( gross ( 100 )); }
Note
Compact for scripts and exercises, classic for real projects. The rest of the language is
identical.
Compile and run
java Calculator.java # run a single file directly
javac Calculator.java # produce Calculator.class
java Calculator
javac -d out $( find src -name "*.java" )
java -cp out com.example.Calculator
void main ( String [] args ) {
if ( args . length < 2 ) {
IO . println ( "usage: java Calculator.java <a> <b>" );
return ;
}
var a = Integer . parseInt ( args [ 0 ] );
var b = Integer . parseInt ( args [ 1 ] );
IO . println ( a + b );
}
Reading errors
Compile time — the program never runs:
Calculator.java:5: error: incompatible types: String cannot be converted to int
int x = "hello";
^
Runtime — read the stack trace bottom-up:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Calculator.divide(Calculator.java:12)
at Calculator.main(Calculator.java:5)
Catching exceptions
try {
var n = Integer . parseInt ( input );
IO . println ( 100 / n );
} catch ( NumberFormatException | ArithmeticException e ) {
IO . println ( "bad input: " + e . getMessage ());
} finally {
IO . println ( "always runs" );
}
Unchecked
Checked
base class
RuntimeException
Exception
must be handled
no
yes — catch or throws
typical
NullPointerException, IllegalArgumentException
IOException, SQLException
String read ( Path p ) throws IOException { // pass it up
return Files . readString ( p );
}
throw new IllegalArgumentException ( "age must not be negative: " + age );
Details in chapter 12 .
Files
import java.nio.file.Files ;
import java.nio.file.Path ;
var path = Path . of ( "notes.txt" );
Files . writeString ( path , "line 1\nline 2\n" );
Files . writeString ( path , "line 3\n" , StandardOpenOption . APPEND );
var content = Files . readString ( path );
for ( var line : Files . readAllLines ( path )) IO . println ( line );
Large files — stream them, and close the stream:
try ( var lines = Files . lines ( Path . of ( "big.log" ))) {
lines . filter ( l -> l . contains ( "ERROR" )). limit ( 10 ). forEach ( IO :: println );
}
try (...) closes everything automatically, even when an exception is thrown:
try ( var writer = Files . newBufferedWriter ( Path . of ( "out.txt" ))) {
writer . write ( "hello" );
writer . newLine ();
}
Paths:
var p = Path . of ( "data" , "2025" , "report.csv" );
Files . exists ( p ); Files . isDirectory ( p ); Files . size ( p );
Files . createDirectories ( p . getParent ());
Files . deleteIfExists ( p );
p . getFileName (); p . toAbsolutePath ();
UTF-8 is the default since Java 18 — no charset arguments needed.
A complete program
import java.nio.file.* ;
void main ( String [] args ) {
if ( args . length == 0 ) {
IO . println ( "usage: java WordCount.java <file>" );
return ;
}
try {
var words = Files . readString ( Path . of ( args [ 0 ] )). toLowerCase (). split ( "\\W+" );
var counts = new java . util . HashMap < String , Integer > ();
for ( var w : words ) {
if ( ! w . isBlank ()) counts . merge ( w , 1 , Integer :: sum );
}
counts . entrySet (). stream ()
. sorted (( a , b ) -> b . getValue () - a . getValue ())
. limit ( 10 )
. forEach ( e -> IO . println ( "%-15s %d" . formatted ( e . getKey (), e . getValue ())));
} catch ( java . io . IOException e ) {
IO . println ( "cannot read file: " + e . getMessage ());
}
}
★ Exercises
Run the word counter above on any text file.
Add a second argument: how many words to print.
Copy a file, adding line numbers.
What happens on a missing file? Catch it and print something useful.
Read a CSV of name;age;city into a list of records.
Trigger a NullPointerException on purpose and read the stack trace.
APIs & HTTP
A web API answers HTTP requests with structured data, usually JSON.
Method
Purpose
Status
Meaning
GET
read
2xx
success
POST
create
3xx
redirect
PUT/PATCH
update
4xx
your mistake (404, 401, 429)
DELETE
delete
5xx
server’s mistake
The built-in HTTP client
import java.net.URI ;
import java.net.http.* ;
void main () throws Exception {
try ( var client = HttpClient . newHttpClient ()) {
var request = HttpRequest . newBuilder ()
. uri ( URI . create ( "https://api.github.com/repos/openjdk/jdk" ))
. header ( "Accept" , "application/json" )
. GET ()
. build ();
var response = client . send ( request , HttpResponse . BodyHandlers . ofString ());
IO . println ( response . statusCode ());
IO . println ( response . body ());
}
}
Configuration:
var client = HttpClient . newBuilder ()
. connectTimeout ( Duration . ofSeconds ( 10 ))
. followRedirects ( HttpClient . Redirect . NORMAL )
. build ();
var request = HttpRequest . newBuilder ( URI . create ( url ))
. timeout ( Duration . ofSeconds ( 20 ))
. header ( "User-Agent" , "java-course/1.0" )
. build ();
POST with a body:
var body = """
{"title": "New task", "done": false}
""" ;
HttpRequest . newBuilder ()
. uri ( URI . create ( "https://example.test/api/tasks" ))
. header ( "Content-Type" , "application/json" )
. POST ( HttpRequest . BodyPublishers . ofString ( body ))
. build ();
Warning
A 404 does not throw. Check response.statusCode() yourself.
if ( response . statusCode () >= 400 ) {
throw new IllegalStateException ( "API error %d" . formatted ( response . statusCode ()));
}
JSON
Java has no JSON parser in the standard library. Use Jackson or Gson:
<dependency>
<groupId> com.fasterxml.jackson.core</groupId>
<artifactId> jackson-databind</artifactId>
<version> 2.18.2</version>
</dependency>
@JsonIgnoreProperties ( ignoreUnknown = true )
record Repository ( String name , String description , int stargazers_count ) {}
var mapper = new ObjectMapper ();
var repo = mapper . readValue ( response . body (), Repository . class );
var json = mapper . writeValueAsString ( repo );
Full example
void main () throws Exception {
var zip = IO . readln ( "ZIP code: " ). strip ();
try ( var client = HttpClient . newHttpClient ()) {
var request = HttpRequest . newBuilder (
URI . create ( "https://api.zippopotam.us/us/" + zip )). build ();
var response = client . send ( request , HttpResponse . BodyHandlers . ofString ());
switch ( response . statusCode ()) {
case 200 -> IO . println ( response . body ());
case 404 -> IO . println ( "unknown ZIP code" );
default -> IO . println ( "unexpected status " + response . statusCode ());
}
}
}
Manners
respect rate limits — on 429, back off instead of retrying immediately
always set timeouts
keys go in environment variables: System.getenv("API_TOKEN")
one HttpClient per application, not per request
★ Exercises
Call any public API; print the status code and the first 200 characters.
Print all response headers (response.headers().map()).
Fetch three URLs one after another and measure the total time — you will parallelise this
in chapter 15 .
Retry up to three times with growing delays when you get a 429.
Read a token from an environment variable and send it as an Authorization header.
What should happen if the variable is missing?