Expected output:
4 3 2 1 0 1 2 3 4
Let me put it into a diagram:
4
  3
    2
      1
        0
      1
    2
  3
4
Let's put that into code:
def bounce(n):
    print(n)
    if n:
        bounce(n - 1)
        print(n)
Or I can see it as a tree traversal - down and up (well, the tree is pretty linear in this case):
↓
  4
↓ | ↑
  3
↓ | ↑
  2
↓ | ↑
  1
↓ | ↑
  0
There are multiple ways how to do a tree traversal, I would pick DFS here - depth-first search.
DFS pseudocode:
procedure DFS(G,v):
    label v as discovered
    for all edges from v to w in G.adjacentEdges(v) do
        if vertex w is not labeled as discovered then
            recursively call DFS(G,w)
Actually it's designed for graphs, not just trees; for trees we don't have to do the "label as discovered" part.
Translate this DFS to Python:
def dfs(node):
    for child_node in node:
        dfs(child_node)
In the 4 3 2 1 0 case we don't need the for becase there is exactly one or zero child nodes - n - 1 as long as n > 0:
def our_dfs(n):
    if n > 0:
        child_node = n - 1
        our_dfs(child_node)
But that does just the traversal and nothing really useful yet. Let's inject our "business logic":
def bounce(n):
    # stuff that happens before we go down
    print(n)
    # descend
    if n > 0:
        child_node = n - 1
        bounce(child_node)
    # stuff that happens after we are back from processing the subtree
    if n > 0:
        print(n)
Because we believe in good craftsmanship and we want to produce clean code (OK I am starting to be joking now) we want functions that do only one thing - one function for DFS, one function that represents our tree, separate function(s) for our business logic:
def dfs(node, child_factory, before_stuff, after_stuff):
    before_stuff(node)
    for child_node in get_child_nodes(node):
        dfs(child_node, child_factory, before_stuff, after_stuff)
    after_stuff(node)
def get_child_nodes(n):
    if n:
        yield n - 1
def print_before(node):
    print(node)
def print_after(node):
    if node:
        print(node)
def bounce(n):
    dfs(n, get_child_nodes, print_before, print_after)
bounce(4)
Maybe we could make the dfs function a bit simpler by using nested function:
def dfs(node, child_factory, before_stuff, after_stuff):
    def f(node):
        before_stuff(node)
        for child_node in get_child_nodes(node):
            f(child_node)
        after_stuff(node)
    f(node)
Hey, lookign at it, I have one more idea... We could modify this to a function that returns a function (closure) that can do DFS:
def make_dfs(child_factory, before_stuff, after_stuff):
    def dfs(node):
        before_stuff(node)
        for child_node in get_child_nodes(node):
            dfs(child_node)
        after_stuff(node)
    return dfs
So the bounce program now becomes:
def get_child_nodes(n):
    if n:
        yield n - 1
def print_before(node):
    print(node)
def print_after(node):
    if node:
        print(node)
def make_dfs(child_factory, before_stuff, after_stuff):
    def dfs(node):
        before_stuff(node)
        for child_node in get_child_nodes(node):
            dfs(child_node)
        after_stuff(node)
    return dfs
bounce = make_dfs(get_child_nodes, print_before, print_after)
bounce(4)
So, what's so cool about this solution? (note: still joking, partially) You know, Python has a recursion limit. There is a finite number of how many function calls can be nested and this number is pretty low. That's a big downside (sometimes even security concern) of processing unknown inputs using recursive functions. So what now? Well, just replace the implementation of make_dfs with something stack-based (see that DFS Wikipedia page) instead of recursion. Done. You don't have to touch anything else.